forge_ops_tracker 0.11.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: af67d3b6d07322ccfd80d56256e5e600c264f60394058acab51b7bfc660435fa
4
- data.tar.gz: 2716944aed397bb03c754900b2e34f3abc49247b43b6a97b411cec6b6a3ccbc2
3
+ metadata.gz: ccbeed25a3172994d20455c19fa79a8336f3c73324ed667fd2f31d2cc05a19f5
4
+ data.tar.gz: 3cb7e1562daf2ccb8f09226bc915b7f00f6e8486ebbf361f8adf0a16c9851afe
5
5
  SHA512:
6
- metadata.gz: 2952a5218507cfceb871821b1f79add47d255b45577ee4bc67a782b574e3d41135d1f971d966846666c3bbcc1de6e86e3c3c7671e8a5ec04b31d9dabf4af0cc4
7
- data.tar.gz: 32efc755d0d05fca0dd4f4354d00808caf135da2c0a22ad2b9d417017a3e73406b6d1b879da3e5862724340d52222fec9d1fbbfebc45efee58101e824b105373
6
+ metadata.gz: 6287470f8bc768e95d1d1afe55f2c7a0e640a4d66ded8a0e3c518e0248031a7f5f76621adf0436d2de3bc7da029733b9debab07e31d2f1b37c7867cfd3aee7e5
7
+ data.tar.gz: b7c996fbf89c2c0a90f6c39c848542cde734242a3fa4efaf450a36b1e7362815322417ceafc89d05a71a453a9965d7b4ab0c4deb2eedad0fdafd6b21808ad790
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
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
+
20
+ ## 0.12.0
21
+
22
+ - Every error event reported during a request now says where it happened: `transaction_name` (the
23
+ same `CheckoutsController#create` name performance samples and traces already use), `endpoint`
24
+ (the HTTP method plus the route as declared in your routes file, e.g. `POST /checkout` or
25
+ `GET /orders/:id`, never the literal path, so no ids or tokens; falls back to
26
+ `POST CheckoutsController#create` before Rails 7.1), and `trace_id` (the request's W3C trace id).
27
+ All three are set as soon as the controller starts handling the request, so an error reported
28
+ from inside the action with `Rails.error.handle`/`record` already has them. Left out entirely
29
+ outside a request (a background job, a console session). Structured fields, so they're never
30
+ PII-scrubbed.
31
+ - Unhandled request errors keep their context. Rails 8.1 reports an unhandled request exception
32
+ from its executor, outside this gem's middleware, after the gem had already cleared its
33
+ per-request state, so the affected user and the breadcrumb trail were missing on exactly those
34
+ errors. A new innermost middleware, `ForgeOpsTracker::Middleware::RequestContext`, now copies the
35
+ user, breadcrumbs, transaction name, endpoint, and trace id onto the exception on its way out
36
+ (re-raising it unchanged), and the error subscriber falls back to that copy.
37
+ - Distributed tracing across services, using the W3C Trace Context standard. A request that
38
+ arrives with a valid `traceparent` header continues that trace instead of starting its own, and
39
+ its root span points at the caller's span. Every outbound `Net::HTTP` call made during a request
40
+ now sends a `traceparent` header whose parent id is that call's own span, so the next service
41
+ (any service that understands the standard, not only ones running this gem) can continue the
42
+ trace. A `traceparent` you set yourself is never overwritten. New `config.propagate_traces`
43
+ (default true) and `config.trace_propagation_targets` (default nil, meaning every host; or an
44
+ array of host Strings, each matching that host and its subdomains, and/or Regexps) control where
45
+ the header goes. A trace id is created for every request even with `track_tracing` off, since it
46
+ is also what links an error to errors in other services.
47
+ - An errored request's trace is always sent, however fast it was: an unhandled exception, or any
48
+ error reported through `Rails.error` while the request was running. Previously only requests
49
+ slower than `trace_capture_threshold_ms` were sent. Fast, successful requests are unchanged.
50
+
3
51
  ## 0.11.0
4
52
 
5
53
  - Database errors now say where to look. When an error comes from a database call
data/README.md CHANGED
@@ -18,6 +18,10 @@ ForgeOpsTracker.configure do |config|
18
18
  config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@getforgeops.net/api/v1/events"
19
19
  config.release = ENV["HEROKU_SLUG_COMMIT"] || `git rev-parse HEAD`.strip
20
20
  config.enabled_environments = %w[production staging] # default; reporting is a no-op elsewhere
21
+
22
+ # Trace propagation to the services you call (see "Tracing" below):
23
+ config.propagate_traces = true # default; false stops sending the traceparent header
24
+ config.trace_propagation_targets = nil # default: every host; or e.g. ["internal.example", /\.svc\z/]
21
25
  end
22
26
  ```
23
27
 
@@ -112,6 +116,29 @@ none of them (or with a signed-out `current_user`, as the `&.` above already han
112
116
  whatever was set. Shows up on an issue's own detail page, and as its own `affected_users_count`
113
117
  alongside the regular event count.
114
118
 
119
+ ## Where an error happened
120
+
121
+ An error reported during a request carries three extra fields, filled in as soon as the
122
+ controller starts handling the request (so an error reported from inside the action with
123
+ `Rails.error.handle`/`.record` has them too):
124
+
125
+ - `transaction_name`: the controller and action, `"CheckoutsController#create"`, the same name
126
+ performance monitoring and traces use.
127
+ - `endpoint`: the HTTP method and the route as declared in your routes file, `"POST /checkout"` or
128
+ `"GET /orders/:id"`. Never the literal path, so an id or a token in the URL never ends up here.
129
+ Before Rails 7.1 (which added the route pattern to the request), this falls back to
130
+ `"POST CheckoutsController#create"`.
131
+ - `trace_id`: the request's W3C trace id, which ForgeOps uses to link this error to errors that
132
+ other services reported for the same trace (see "Tracing" below).
133
+
134
+ They're left out entirely outside a request (a background job, a console session), and they're
135
+ never PII-scrubbed, like `environment` and `release`: they're structured fields, not free text.
136
+
137
+ An unhandled exception keeps these, the affected user, and the breadcrumb trail, even though Rails
138
+ reports it after this gem's middleware has already finished with the request: the innermost
139
+ middleware, `ForgeOpsTracker::Middleware::RequestContext`, copies them onto the exception on its
140
+ way out and re-raises it unchanged.
141
+
115
142
  ## Breadcrumbs
116
143
 
117
144
  A trail of what happened right before an error, on by default, no setup needed: every SQL query,
@@ -329,6 +356,107 @@ Requires a ForgeOps plan that includes performance monitoring, the same plan fea
329
356
  monitoring itself already requires; on a plan that doesn't, the periodic flushes are simply
330
357
  rejected server-side and dropped, exactly like any other delivery failure.
331
358
 
359
+ ## Tracing
360
+
361
+ On by default: every request records a nested tree of spans (the controller action, SQL queries,
362
+ outbound `Net::HTTP` calls, Redis calls, and anything you wrap in
363
+ `ForgeOpsTracker.span("PaymentService#charge") { ... }`), kept in memory until the request ends.
364
+ Only some requests' traces are actually sent:
365
+
366
+ - a request at least `trace_capture_threshold_ms` slow (default 1000), and
367
+ - **every request that errored, however fast**: an unhandled exception, or any error reported
368
+ through `Rails.error` (`handle`/`record`/`report`) while the request was running. The spans
369
+ leading up to an error are exactly what you want next to it.
370
+
371
+ A fast, successful request never sends anything.
372
+
373
+ ```ruby
374
+ ForgeOpsTracker.configure do |config|
375
+ config.track_tracing = false # opt out of recording spans entirely
376
+ config.trace_capture_threshold_ms = 500 # default 1000
377
+ end
378
+ ```
379
+
380
+ ### Following a request across services
381
+
382
+ Traces use the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard, so a request
383
+ can be followed from one service into the next, whichever language or tracing library the other
384
+ service uses:
385
+
386
+ - **Incoming**: a request that arrives with a valid `traceparent` header continues that trace
387
+ (same trace id), and its root span records the caller's span as its parent. A missing or
388
+ malformed header just starts a new trace.
389
+ - **Outgoing**: every outbound `Net::HTTP` call made during a request (Faraday's `net_http`
390
+ adapter, HTTParty, and RestClient included) gets a `traceparent` header whose parent id is that
391
+ call's own span, so the next service's spans nest under it. A `traceparent` you set yourself is
392
+ never replaced.
393
+
394
+ A trace id exists for every request even with `track_tracing` off, and the header is still sent,
395
+ since the trace id is also what links an error here to an error in the service you called.
396
+ Narrow or turn off where the header goes, for example if a third-party API rejects unknown
397
+ headers:
398
+
399
+ ```ruby
400
+ ForgeOpsTracker.configure do |config|
401
+ config.propagate_traces = false # never send traceparent (default true)
402
+
403
+ # Default nil: every host. A String matches that host and its subdomains ("internal.example"
404
+ # matches "orders.internal.example", not "notinternal.example"); a Regexp is matched against
405
+ # the host.
406
+ config.trace_propagation_targets = [ "internal.example", /\A10\.0\./ ]
407
+ end
408
+ ```
409
+
410
+ Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, traces are
411
+ simply rejected server-side and dropped, exactly like any other delivery failure. The `trace_id`
412
+ on error events is sent either way.
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
+
332
460
  ## Custom metrics
333
461
 
334
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
 
@@ -17,6 +17,8 @@ module ForgeOpsTracker
17
17
  attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
18
18
  attr_accessor :gauge_poll_interval
19
19
  attr_accessor :track_tracing, :trace_capture_threshold_ms
20
+ attr_accessor :propagate_traces, :trace_propagation_targets
21
+ attr_accessor :detect_changes, :track_env_var_names
20
22
 
21
23
  def initialize
22
24
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -124,6 +126,46 @@ module ForgeOpsTracker
124
126
  # PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
125
127
  # many requests, this describes a single one being outright slow.
126
128
  @trace_capture_threshold_ms = 1_000
129
+ # Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
130
+ # Net::HTTP request made during a request, so the service on the other end can continue this
131
+ # request's trace instead of starting its own (see Integrations::NetHTTP). On by default, the
132
+ # same as every other automatic behavior here, and independent of track_tracing: the header
133
+ # carries the trace id that links an error here to an error there, which is useful with or
134
+ # without spans. Never overwrites a traceparent the request already has.
135
+ @propagate_traces = true
136
+ # Which outbound hosts get that header. nil (the default) means every host. Otherwise an
137
+ # Array of Strings and/or Regexps: a String matches that exact host or any subdomain of it
138
+ # ("example.com" matches "example.com" and "api.example.com", but not "badexample.com"), a
139
+ # Regexp is matched against the host. Worth narrowing for an app that calls third-party APIs
140
+ # which reject unknown headers, or that shouldn't learn this app's trace ids at all.
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
151
+ end
152
+
153
+ # Whether an outbound request to host should carry a traceparent header; see
154
+ # propagate_traces/trace_propagation_targets above. Case-insensitive, since hostnames are.
155
+ def propagate_trace_to?(host)
156
+ return false unless propagate_traces
157
+ return true if trace_propagation_targets.nil?
158
+
159
+ host = host.to_s.downcase
160
+ Array(trace_propagation_targets).any? do |target|
161
+ case target
162
+ when Regexp
163
+ target.match?(host)
164
+ else
165
+ target = target.to_s.downcase.delete_prefix(".")
166
+ !target.empty? && (host == target || host.end_with?(".#{target}"))
167
+ end
168
+ end
127
169
  end
128
170
 
129
171
  def api_key
@@ -199,11 +241,30 @@ module ForgeOpsTracker
199
241
  uri
200
242
  end
201
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
+
202
252
  def enabled?
203
253
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
204
254
  end
205
255
 
206
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
+
207
268
  def parsed_dsn
208
269
  return nil if blank?(dsn)
209
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
@@ -28,11 +28,33 @@ module ForgeOpsTracker
28
28
  # hands this class the Rack env, so ForgeOpsTracker::Middleware::UserContext (Warden
29
29
  # auto-detection) and ForgeOpsTracker.set_user (the manual override/fallback) both
30
30
  # communicate with this exact same thread-local instead, the only channel available here.
31
- # ForgeOpsTracker::Middleware::BreadcrumbContext's own per-request buffer works the same way.
31
+ # ForgeOpsTracker::Middleware::BreadcrumbContext's own per-request buffer works the same way,
32
+ # and so does Middleware::RequestContext's RequestState (trace id, transaction name,
33
+ # endpoint).
34
+ #
35
+ # Each live thread-local wins when it's set; the snapshot Middleware::RequestContext attached
36
+ # to the exception on its way out of the request is the fallback. That fallback is the whole
37
+ # story for an unhandled request error on Rails 8.1: ActionDispatch::Executor reports it from
38
+ # outside every middleware this gem installs, after all of their ensure blocks have already
39
+ # cleared every one of these thread-locals (see that middleware's own comment).
40
+ original_error = error
32
41
  error = unwrap_retry_control_flow(error)
33
- user = Thread.current[:forge_ops_tracker_current_user]
34
- breadcrumbs = Thread.current[:forge_ops_tracker_breadcrumbs]&.all || []
35
- delivery_queue.push(event_builder.build(error, context: context, user: user, breadcrumbs: breadcrumbs))
42
+ snapshot = Middleware::RequestContext.snapshot_for(original_error) || Middleware::RequestContext.snapshot_for(error) || {}
43
+ state = Middleware::RequestContext.current
44
+
45
+ # A handled error reported during a request (Rails.error.handle/record/report) is what marks
46
+ # it errored here; an unhandled one was already marked by RequestContext's own rescue. Either
47
+ # way Middleware::SpanTracing then sends this request's trace however fast it was.
48
+ state&.mark_errored!
49
+
50
+ user = Thread.current[:forge_ops_tracker_current_user] || snapshot[:user]
51
+ breadcrumbs = Thread.current[:forge_ops_tracker_breadcrumbs]&.all || snapshot[:breadcrumbs] || []
52
+ delivery_queue.push(event_builder.build(
53
+ error, context: context, user: user, breadcrumbs: breadcrumbs,
54
+ transaction_name: state&.transaction_name || snapshot[:transaction_name],
55
+ endpoint: state&.endpoint || snapshot[:endpoint],
56
+ trace_id: state&.trace_id || snapshot[:trace_id]
57
+ ))
36
58
  nil
37
59
  rescue StandardError => e
38
60
  configuration.logger&.debug { "[ForgeOpsTracker] report failed: #{e.class}: #{e.message}" }
@@ -27,7 +27,15 @@ module ForgeOpsTracker
27
27
  @configuration = configuration
28
28
  end
29
29
 
30
- def build(error, context: {}, user: nil, breadcrumbs: [])
30
+ # transaction_name/endpoint/trace_id describe the request the error happened in (see
31
+ # RequestState): transaction_name is the same "CheckoutsController#create" name the request's
32
+ # root span and performance samples use, endpoint the human-readable route ("POST /checkout"),
33
+ # trace_id the request's 32-character lowercase hex W3C trace id, which is also what links this
34
+ # error to errors other services reported for the same trace. All three are nil outside a
35
+ # request (a background job, a console session) and each key is left out of the payload
36
+ # entirely when nil, the same "omit rather than send an empty value" treatment user and
37
+ # breadcrumbs already get.
38
+ def build(error, context: {}, user: nil, breadcrumbs: [], transaction_name: nil, endpoint: nil, trace_id: nil)
31
39
  payload = {
32
40
  exception_class: error.class.name,
33
41
  message: error.message.to_s,
@@ -42,6 +50,9 @@ module ForgeOpsTracker
42
50
  }
43
51
  payload[:user] = user if user && !user.empty?
44
52
  payload[:breadcrumbs] = breadcrumbs if breadcrumbs && !breadcrumbs.empty?
53
+ payload[:transaction_name] = transaction_name if transaction_name
54
+ payload[:endpoint] = endpoint if endpoint
55
+ payload[:trace_id] = trace_id if trace_id
45
56
  attach_sql(payload, error)
46
57
  scrub(payload)
47
58
  end
@@ -49,9 +60,11 @@ module ForgeOpsTracker
49
60
  private
50
61
  attr_reader :configuration
51
62
 
52
- # exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone;
53
- # structured fields this gem or the host app sets deliberately, not free text an exception
54
- # or its context could accidentally spill sensitive data into. user specifically is a
63
+ # exception_class/occurred_at/environment/release/server_name/sdk_name/user/transaction_name/
64
+ # endpoint/trace_id are left alone; structured fields this gem or the host app sets
65
+ # deliberately, not free text an exception or its context could accidentally spill sensitive
66
+ # data into. endpoint in particular is a declared route pattern ("GET /orders/:id"), never
67
+ # the literal request path, so it has no id or token in it to redact. user specifically is a
55
68
  # deliberate exemption, not an oversight: the server's own PiiScrubber-equivalent would
56
69
  # otherwise redact the exact email address this field exists to carry. breadcrumbs is *not*
57
70
  # exempt, unlike user: console-style/query/request trail entries are exactly the kind of free
@@ -23,14 +23,25 @@ module ForgeOpsTracker
23
23
  # confirmed directly that Net::HTTP connects lazily, inside #request itself, the first time
24
24
  # it's called on a not-yet-started instance, so that path's own connection failures are
25
25
  # caught exactly like any other #request failure.
26
+ #
27
+ # Also propagates the current request's trace to the service being called, as a W3C
28
+ # `traceparent` header (see TraceParent, and Configuration#propagate_traces/
29
+ # trace_propagation_targets for turning it off or narrowing it to specific hosts). The
30
+ # header's parent id is this outbound call's own span id, generated before the call is made
31
+ # (the span itself can only be recorded afterward, once its duration is known) and then
32
+ # recorded with that exact id, so the downstream service's root span points at a span that
33
+ # really exists in this trace. Only inside a request (a RequestState to take the trace id
34
+ # from); a background job or a console session has no trace to continue.
26
35
  module Timing
27
36
  # The guard (return super unless ...) skips timing, but the ensure block below still runs
28
37
  # on every exit path regardless, same as any ensure; start staying nil is what actually
29
38
  # skips recording there, not a second guard duplicating this one.
30
39
  def request(req, body = nil, &block)
31
40
  configuration = ForgeOpsTracker.configuration
32
- return super unless (configuration.track_performance || configuration.track_breadcrumbs || configuration.track_failures || configuration.track_tracing) && configuration.enabled?
41
+ return super unless (configuration.track_performance || configuration.track_breadcrumbs || configuration.track_failures || configuration.track_tracing || configuration.propagate_traces) && configuration.enabled?
33
42
 
43
+ span_id = ForgeOpsTracker::TraceParent.generate_span_id
44
+ propagate_trace(req, span_id, configuration)
34
45
  start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
35
46
  response = super
36
47
  rescue StandardError => e
@@ -102,12 +113,26 @@ module ForgeOpsTracker
102
113
  # it actually took.
103
114
  start_time = ::Time.now.utc - (duration_ms / 1000.0)
104
115
  buffer.record(
105
- span_id: SecureRandom.hex(8), name: "#{req.method} #{address}", kind: "http",
116
+ span_id: span_id, name: "#{req.method} #{address}", kind: "http",
106
117
  started_at: start_time, duration_ms: duration_ms, data: { status: status }.compact
107
118
  )
108
119
  end
109
120
  end
110
121
  end
122
+
123
+ private
124
+ # Leaves a traceparent the caller already set alone: whoever set it explicitly knows
125
+ # better than this gem which trace the call belongs to. Never raises, since a failure to
126
+ # add a header must never stop the host app's own request from going out.
127
+ def propagate_trace(req, span_id, configuration)
128
+ state = ForgeOpsTracker::Middleware::RequestContext.current
129
+ return unless state && configuration.propagate_trace_to?(address)
130
+ return if req[ForgeOpsTracker::TraceParent::HEADER]
131
+
132
+ req[ForgeOpsTracker::TraceParent::HEADER] = ForgeOpsTracker::TraceParent.build(trace_id: state.trace_id, span_id: span_id)
133
+ rescue StandardError
134
+ nil
135
+ end
111
136
  end
112
137
  end
113
138
  end
@@ -0,0 +1,90 @@
1
+ module ForgeOpsTracker
2
+ module Middleware
3
+ # Publishes this request's RequestState (trace id, remote parent span id, transaction name,
4
+ # endpoint, errored flag; see that class) as a thread-local for the duration of the request,
5
+ # cleared in an ensure for the same "Puma reuses threads" reason every other middleware in this
6
+ # gem already gives. Not gated on track_tracing: the trace id links an error to errors in other
7
+ # services even when no spans are ever recorded.
8
+ #
9
+ # Its second job is the reason it has to be the *innermost* of this gem's middlewares (added
10
+ # last in Railtie, so app.middleware.use puts it closest to the app). Rails 8.1 reports an
11
+ # unhandled request exception from ActionDispatch::Executor, which sits outside all of this
12
+ # gem's middlewares; by the time ErrorSubscriber#report runs for that exception, every one of
13
+ # their ensure blocks has already cleared its thread-local, so the user, the breadcrumb trail,
14
+ # and the trace id/endpoint would all be gone for exactly the errors that matter most. Rescuing
15
+ # here, innermost, is the last moment all of those are still set: this copies them onto the
16
+ # exception object itself (see SNAPSHOT_IVAR) before re-raising it unchanged, and
17
+ # ErrorSubscriber#report falls back to that copy whenever the live thread-locals are gone.
18
+ #
19
+ # Rescues Exception, not StandardError, and always re-raises: this only ever observes an
20
+ # exception on its way out, it never handles one, so there's no reason to let anything slip
21
+ # past without its context attached.
22
+ class RequestContext
23
+ THREAD_KEY = :forge_ops_tracker_request_context
24
+
25
+ # An instance variable on the exception object, not a wrapper exception or a side table: the
26
+ # exception is the one thing guaranteed to travel from here to wherever Rails ends up
27
+ # reporting it, and a plain ivar leaves its class, message, and backtrace exactly as the host
28
+ # app raised them.
29
+ SNAPSHOT_IVAR = :@__forge_ops_tracker_request_snapshot
30
+
31
+ def initialize(app, configuration: ForgeOpsTracker.configuration)
32
+ @app = app
33
+ @configuration = configuration
34
+ end
35
+
36
+ def call(env)
37
+ return app.call(env) unless configuration.enabled?
38
+
39
+ state = RequestState.for(env)
40
+ Thread.current[THREAD_KEY] = state
41
+ app.call(env)
42
+ rescue Exception => e
43
+ if state
44
+ state.mark_errored!
45
+ self.class.attach_snapshot(e, state)
46
+ end
47
+ raise
48
+ ensure
49
+ Thread.current[THREAD_KEY] = nil
50
+ end
51
+
52
+ class << self
53
+ def current
54
+ Thread.current[THREAD_KEY]
55
+ end
56
+
57
+ # Never raises, whatever it's handed: a frozen exception (some libraries freeze theirs, and
58
+ # a frozen object rejects instance_variable_set with a FrozenError) just goes without a
59
+ # snapshot, and so does anything else unexpected, since failing here would replace the host
60
+ # app's own exception with one of this gem's. The first snapshot wins if the same exception
61
+ # object somehow passes through twice: the innermost one saw the request closest to where
62
+ # it actually failed.
63
+ def attach_snapshot(error, state)
64
+ return if error.frozen? || error.instance_variable_defined?(SNAPSHOT_IVAR)
65
+
66
+ error.instance_variable_set(SNAPSHOT_IVAR, {
67
+ user: Thread.current[:forge_ops_tracker_current_user],
68
+ breadcrumbs: Thread.current[:forge_ops_tracker_breadcrumbs]&.all,
69
+ transaction_name: state.transaction_name,
70
+ endpoint: state.endpoint,
71
+ trace_id: state.trace_id
72
+ }.freeze)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ def snapshot_for(error)
78
+ return nil unless error.instance_variable_defined?(SNAPSHOT_IVAR)
79
+
80
+ error.instance_variable_get(SNAPSHOT_IVAR)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+
86
+ private
87
+ attr_reader :app, :configuration
88
+ end
89
+ end
90
+ end
@@ -3,15 +3,29 @@ module ForgeOpsTracker
3
3
  # Gives every request its own fresh SpanBuffer (see that class for the stack/nesting
4
4
  # mechanics), the same "Thread.current, cleared in an ensure" shape BreadcrumbContext already
5
5
  # uses and for the identical reason: Puma reuses threads across requests, so a buffer left set
6
- # would leak one request's spans into a later, unrelated one handled on the same thread.
6
+ # would leak one request's spans into a later, unrelated one handled on the same thread. The
7
+ # buffer's trace_id and remote parent span id come from the request's RequestState, never
8
+ # generated here, so a trace this sends and an error event from the same request always agree
9
+ # on which trace they belong to (see RequestState for why that object is shared through the
10
+ # Rack env rather than the thread-local RequestContext publishes: this middleware sits outside
11
+ # that one, and runs first).
7
12
  #
8
13
  # The actual send-or-don't decision lives here, not in SpanBuffer or SpanQueue: only once
9
14
  # app.call returns does this middleware know the root span's own final duration (recorded by
10
15
  # Railtie's process_action.action_controller subscription, which fires from *inside* that
11
16
  # call, after every nested query/HTTP/Redis call has already run), so this is the first point
12
17
  # that can compare it against configuration.trace_capture_threshold_ms and decide the trace was
13
- # never worth sending in the first place - the entire reason a normal, fast request never costs
18
+ # never worth sending in the first place: the entire reason a normal, fast request never costs
14
19
  # a single byte over the wire, unlike PerformanceSample's own always-on aggregate reporting.
20
+ #
21
+ # An errored request's trace is always sent too, however fast it was, since the waterfall of
22
+ # what led up to an error is exactly what an issue page wants to show next to it. "Errored"
23
+ # means the RequestState was marked by either path: an unhandled exception passing through
24
+ # RequestContext on its way out, or ErrorSubscriber#report receiving a handled error during the
25
+ # request. Both have already happened by the time this ensure runs; the unhandled one is only
26
+ # *reported* later (by ActionDispatch::Executor, outside every middleware here), but it's
27
+ # marked on the way through, which is what counts. Requires the root span to have been
28
+ # recorded, see SpanBuffer#root_recorded? for why.
15
29
  class SpanTracing
16
30
  def initialize(app, configuration: ForgeOpsTracker.configuration)
17
31
  @app = app
@@ -21,11 +35,12 @@ module ForgeOpsTracker
21
35
  def call(env)
22
36
  return app.call(env) unless configuration.track_tracing && configuration.enabled?
23
37
 
24
- buffer = SpanBuffer.new
38
+ state = RequestState.for(env)
39
+ buffer = SpanBuffer.new(trace_id: state.trace_id, parent_span_id: state.parent_span_id)
25
40
  Thread.current[:forge_ops_tracker_spans] = buffer
26
41
  app.call(env)
27
42
  ensure
28
- if buffer&.slow?(configuration.trace_capture_threshold_ms)
43
+ if buffer && send_trace?(buffer, state)
29
44
  Railtie.span_queue.push(trace_id: buffer.trace_id, spans: buffer.spans)
30
45
  end
31
46
  Thread.current[:forge_ops_tracker_spans] = nil
@@ -33,6 +48,12 @@ module ForgeOpsTracker
33
48
 
34
49
  private
35
50
  attr_reader :app, :configuration
51
+
52
+ def send_trace?(buffer, state)
53
+ return true if buffer.slow?(configuration.trace_capture_threshold_ms)
54
+
55
+ state.errored? && buffer.root_recorded?
56
+ end
36
57
  end
37
58
  end
38
59
  end
@@ -16,6 +16,37 @@ module ForgeOpsTracker
16
16
  "#{payload[:controller]}##{payload[:action]}"
17
17
  end
18
18
 
19
+ # A human-readable route for an error event's `endpoint` field, "<HTTP method> <route pattern>"
20
+ # ("POST /checkout", "GET /orders/:id"): the route as it was declared in config/routes.rb, never
21
+ # the literal request path, so it stays low-cardinality and never carries an id or a token, the
22
+ # same reasoning every transaction_name in this system already follows. payload[:request] is
23
+ # only present in Rails 7.1+, the same release that added
24
+ # ActionDispatch::Request#route_uri_pattern; on anything older (or a request with no matched
25
+ # route pattern at all) this falls back to the controller/action name instead
26
+ # ("GET CheckoutsController#create"), still meaningful, just less familiar-looking than a URL.
27
+ def controller_endpoint(payload)
28
+ target = route_pattern(payload[:request]) || controller_transaction_name(payload)
29
+ method = payload[:method]
30
+ method.nil? || method.to_s.empty? ? target : "#{method} #{target}"
31
+ end
32
+
33
+ # Rails appends an optional "(.:format)" segment to nearly every route pattern it generates;
34
+ # it's noise in a label a person reads, so it's dropped. Defensive about everything else, since
35
+ # this runs inside a notification subscriber on every single request: a request object without
36
+ # the method, a nil/empty pattern, or the method itself raising all just mean "no pattern," and
37
+ # the caller falls back.
38
+ def route_pattern(request)
39
+ return nil unless request.respond_to?(:route_uri_pattern)
40
+
41
+ pattern = request.route_uri_pattern
42
+ return nil if pattern.nil? || pattern.to_s.empty?
43
+
44
+ pattern = pattern.to_s.delete_suffix("(.:format)")
45
+ pattern.empty? ? "/" : pattern
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
19
50
  # nil means "don't record this one": Rails' own sql.active_record fires for two kinds of
20
51
  # noise that would drown out real query timing otherwise. "SCHEMA" is Rails' own internal
21
52
  # introspection (column lookups, etc.), never something a customer's own code triggered;
@@ -73,6 +73,31 @@ module ForgeOpsTracker
73
73
  app.middleware.use ForgeOpsTracker::Middleware::SpanTracing, configuration: ForgeOpsTracker.configuration
74
74
  end
75
75
 
76
+ # Must stay the last app.middleware.use in this file, so RequestContext is the innermost of
77
+ # this gem's middlewares: its rescue is what copies the user, the breadcrumb trail, and the
78
+ # trace id/endpoint onto an unhandled exception before any of the middlewares above clear their
79
+ # thread-locals in their own ensure blocks, and ActionDispatch::Executor (which reports that
80
+ # exception in Rails 8.1) sits outside all of them. See that middleware's own comment.
81
+ # Initializers within one Railtie run in the order they're defined, and app.middleware.use
82
+ # appends, so being defined after track_tracing is what makes it innermost.
83
+ #
84
+ # start_processing.action_controller fires as the controller begins handling the request, before
85
+ # the action itself runs (unlike process_action.action_controller below, which only fires once
86
+ # it has finished), so a handled error reported from inside the action (Rails.error.handle/
87
+ # record) already knows which transaction and endpoint it happened in. Its payload carries
88
+ # :controller/:action/:method, plus :request from Rails 7.1 on (see
89
+ # PerformanceInstrumentation.controller_endpoint for what each is used for).
90
+ initializer "forge_ops_tracker.request_context" do |app|
91
+ configuration = ForgeOpsTracker.configuration
92
+ app.middleware.use ForgeOpsTracker::Middleware::RequestContext, configuration: configuration
93
+
94
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*args|
95
+ next unless configuration.enabled?
96
+
97
+ ForgeOpsTracker::Middleware::RequestContext.current&.controller_started(args.last)
98
+ end
99
+ end
100
+
76
101
  # Rails already fires "process_action.action_controller" after every request with the
77
102
  # controller/action and how long it took (event.duration, in ms) built in, so unlike session
78
103
  # tracking above (which needed a Rack middleware because there's no single existing hook for
@@ -130,8 +155,8 @@ module ForgeOpsTracker
130
155
  # this exact call site already established for breadcrumbs): this is the one call in the
131
156
  # whole gem that ever passes root: true, since process_action.action_controller is the only
132
157
  # instrumentation point that wraps a genuinely whole request. See SpanBuffer#record's own
133
- # comment for why root: true has to force parent_span_id nil explicitly here rather than
134
- # reading it off the (already-popped-by-now) stack.
158
+ # comment for why root: true takes its parent_span_id from the incoming traceparent header
159
+ # (nil when there wasn't one) rather than reading it off the (already-popped-by-now) stack.
135
160
  if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
136
161
  buffer.pop
137
162
  buffer.record(
@@ -280,6 +305,14 @@ module ForgeOpsTracker
280
305
  # sidekiq, ActiveJob's redis queue adapter, ...), which this gem has no control over; deferring
281
306
  # the defined? check to after every railtie (including this app's own dependencies) has
282
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
+
283
316
  config.after_initialize do
284
317
  next unless defined?(::RedisClient)
285
318
 
@@ -0,0 +1,69 @@
1
+ module ForgeOpsTracker
2
+ # Everything this gem knows about the current request that isn't already owned by one of the
3
+ # older, single-purpose thread-locals (the user, the breadcrumb trail, the span buffer): which
4
+ # trace it belongs to, which remote span called it (if any), what it's called, and whether it
5
+ # errored. One instance per request, shared by every piece of this gem that needs any of those:
6
+ #
7
+ # - Middleware::RequestContext publishes it as a thread-local
8
+ # (Thread.current[:forge_ops_tracker_request_context]) for the duration of the request, so
9
+ # ErrorSubscriber#report, the start_processing.action_controller subscription, and the
10
+ # Net::HTTP integration can reach it (none of them are ever handed the Rack env).
11
+ # - Middleware::SpanTracing reads the same instance's trace_id/parent_span_id for its SpanBuffer,
12
+ # and its errored? flag once the request is over.
13
+ #
14
+ # The trace_id exists for every request, not only when span tracing is on: it's also what an
15
+ # error event carries so ForgeOps can link it to errors reported by other services taking part in
16
+ # the same trace, which works on any plan and with track_tracing off entirely.
17
+ #
18
+ # Memoized in the Rack env (see .for) rather than created by one specific middleware and handed to
19
+ # the other: SpanTracing sits *outside* RequestContext in the stack (RequestContext must be the
20
+ # innermost of this gem's middlewares; see its own comment for why), so SpanTracing runs first and
21
+ # needs the trace_id before RequestContext has run at all, then keeps needing this same object
22
+ # after RequestContext's own ensure has already cleared the thread-local. Whichever of the two
23
+ # reaches a given request first creates it; the other gets the identical instance back.
24
+ class RequestState
25
+ ENV_KEY = "forge_ops_tracker.request_state".freeze
26
+
27
+ attr_reader :trace_id, :parent_span_id
28
+ attr_accessor :transaction_name, :endpoint
29
+
30
+ def self.for(env)
31
+ env[ENV_KEY] ||= from_env(env)
32
+ end
33
+
34
+ # Continues the caller's trace when the request arrived with a usable W3C traceparent header,
35
+ # remembering the caller's span as this request's remote parent; starts a fresh trace otherwise.
36
+ def self.from_env(env)
37
+ incoming = TraceParent.parse(env[TraceParent::RACK_ENV_KEY])
38
+ if incoming
39
+ new(trace_id: incoming[:trace_id], parent_span_id: incoming[:parent_span_id])
40
+ else
41
+ new
42
+ end
43
+ end
44
+
45
+ def initialize(trace_id: TraceParent.generate_trace_id, parent_span_id: nil)
46
+ @trace_id = trace_id
47
+ @parent_span_id = parent_span_id
48
+ @transaction_name = nil
49
+ @endpoint = nil
50
+ @errored = false
51
+ end
52
+
53
+ # Called from the start_processing.action_controller subscription (see Railtie), the earliest
54
+ # point Rails names the controller/action handling this request: early enough that an error
55
+ # reported partway through the action itself (Rails.error.handle/record) already carries both.
56
+ def controller_started(payload)
57
+ self.transaction_name = PerformanceInstrumentation.controller_transaction_name(payload)
58
+ self.endpoint = PerformanceInstrumentation.controller_endpoint(payload)
59
+ end
60
+
61
+ def mark_errored!
62
+ @errored = true
63
+ end
64
+
65
+ def errored?
66
+ @errored
67
+ end
68
+ end
69
+ end
@@ -3,9 +3,13 @@ require "securerandom"
3
3
  module ForgeOpsTracker
4
4
  # Accumulates one request's own nested call tree in-process; the tracing analog to
5
5
  # BreadcrumbBuffer's per-thread trail. ForgeOpsTracker::Middleware::SpanTracing resets this to a
6
- # fresh buffer at the start of every request, with a freshly generated trace_id and root
7
- # span_id, and reads it back once the request is over to decide whether this trace is worth
8
- # sending at all (see that middleware's own comment for exactly where and why).
6
+ # fresh buffer at the start of every request, with a freshly generated root span_id and the
7
+ # request's own trace_id (and remote parent span id, when the request continued a trace another
8
+ # service started; see RequestState), and reads it back once the request is over to decide
9
+ # whether this trace is worth sending at all (see that middleware's own comment for exactly where
10
+ # and why). trace_id/parent_span_id are passed in rather than generated here: the trace_id
11
+ # belongs to the request, not to span tracing, since an error event carries it too, even with
12
+ # tracing off, and the two have to agree for ForgeOps to link them.
9
13
  #
10
14
  # The stack exists so nesting can be reconstructed correctly even though most of what's recorded
11
15
  # here (a query, an outbound HTTP call, a Redis call) only has a start+duration known *after* the
@@ -19,10 +23,11 @@ module ForgeOpsTracker
19
23
  # already run) naturally nests under it without the root having to exist as a real recorded span
20
24
  # first.
21
25
  class SpanBuffer
22
- attr_reader :trace_id, :root_span_id, :spans
26
+ attr_reader :trace_id, :root_span_id, :parent_span_id, :spans
23
27
 
24
- def initialize
25
- @trace_id = SecureRandom.hex(16)
28
+ def initialize(trace_id: SecureRandom.hex(16), parent_span_id: nil)
29
+ @trace_id = trace_id
30
+ @parent_span_id = parent_span_id
26
31
  @root_span_id = SecureRandom.hex(8)
27
32
  @spans = []
28
33
  @stack = [ @root_span_id ]
@@ -42,12 +47,14 @@ module ForgeOpsTracker
42
47
  end
43
48
 
44
49
  # root: true only for the one call that records the request's own root span (see Railtie's
45
- # process_action.action_controller subscription): forces parent_span_id nil explicitly, since
46
- # by the time that fires, the root's own span_id has already been popped off the stack (there's
47
- # nothing left on it to read back as "the parent" at that point - correctly, since the root has
48
- # no parent of its own).
50
+ # process_action.action_controller subscription): takes its parent from the incoming
51
+ # traceparent header's span id instead of the stack, since by the time that fires, the root's
52
+ # own span_id has already been popped off it (there's nothing left on it to read back as "the
53
+ # parent" at that point, correctly: nothing inside this process is the root's parent). nil for
54
+ # a request that started its own trace; the calling service's span otherwise, which ForgeOps
55
+ # treats as a remote parent because it's never part of this same batch.
49
56
  def record(span_id:, name:, kind:, started_at:, duration_ms:, data: {}, root: false)
50
- parent_span_id = root ? nil : current_parent_id
57
+ parent_span_id = root ? @parent_span_id : current_parent_id
51
58
  spans << {
52
59
  span_id: span_id, parent_span_id: parent_span_id, name: name, kind: kind,
53
60
  started_at: started_at.utc.iso8601(3), duration_ms: duration_ms, data: data
@@ -61,5 +68,13 @@ module ForgeOpsTracker
61
68
  def slow?(threshold_ms)
62
69
  @root_duration_ms && @root_duration_ms >= threshold_ms
63
70
  end
71
+
72
+ # Whether the root span was ever recorded at all. Middleware::SpanTracing only sends an errored
73
+ # request's trace when this is true: without the root, every other span in the buffer points at
74
+ # a parent that was never recorded, which ForgeOps would misread as a remote parent in some
75
+ # other service rather than a missing root in this one.
76
+ def root_recorded?
77
+ !@root_duration_ms.nil?
78
+ end
64
79
  end
65
80
  end
@@ -0,0 +1,62 @@
1
+ require "securerandom"
2
+
3
+ module ForgeOpsTracker
4
+ # Reads and writes the W3C Trace Context `traceparent` header
5
+ # (https://www.w3.org/TR/trace-context/), the vendor-neutral format for carrying one trace across
6
+ # service boundaries: `00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>`. Used in both
7
+ # directions: Middleware::RequestContext parses an incoming one so this request continues the
8
+ # caller's trace instead of starting its own, and Integrations::NetHTTP builds an outgoing one so
9
+ # the next service along continues this request's.
10
+ #
11
+ # Deliberately strict on the way in, the same posture the spec itself asks receivers to take: a
12
+ # malformed value, uppercase hex, the reserved version "ff", or an all-zero trace/parent id are
13
+ # all treated as "no usable header at all" (parse returns nil and the request starts a fresh
14
+ # trace), never half-trusted. A version this gem doesn't know yet is still accepted as long as
15
+ # its first four fields have version 00's shape, which is exactly what the spec says a version-00
16
+ # parser should do with a future version; version 00 itself must have exactly four fields.
17
+ module TraceParent
18
+ HEADER = "traceparent".freeze
19
+ RACK_ENV_KEY = "HTTP_TRACEPARENT".freeze
20
+
21
+ PATTERN = /\A(?<version>[0-9a-f]{2})-(?<trace_id>[0-9a-f]{32})-(?<parent_id>[0-9a-f]{16})-(?<flags>[0-9a-f]{2})(?<rest>-.*)?\z/
22
+ INVALID_TRACE_ID = ("0" * 32).freeze
23
+ INVALID_PARENT_ID = ("0" * 16).freeze
24
+
25
+ # Always "01" (sampled) on the way out: this gem decides whether to actually send a trace only
26
+ # once the request is over (see Middleware::SpanTracing), long after this header has already
27
+ # gone out on an outbound call, so there's no honest earlier answer to give than "this may be
28
+ # recorded." A downstream service is free to make its own decision either way.
29
+ SAMPLED_FLAGS = "01".freeze
30
+
31
+ module_function
32
+
33
+ # Returns { trace_id:, parent_span_id: } for a usable header, nil for anything else (absent,
34
+ # blank, malformed, or one of the explicitly invalid values above).
35
+ def parse(value)
36
+ return nil unless value.is_a?(String)
37
+
38
+ match = PATTERN.match(value.strip)
39
+ return nil unless match
40
+ return nil if match[:version] == "ff"
41
+ return nil if match[:version] == "00" && match[:rest]
42
+ return nil if match[:trace_id] == INVALID_TRACE_ID || match[:parent_id] == INVALID_PARENT_ID
43
+
44
+ { trace_id: match[:trace_id], parent_span_id: match[:parent_id] }
45
+ end
46
+
47
+ def build(trace_id:, span_id:)
48
+ "00-#{trace_id}-#{span_id}-#{SAMPLED_FLAGS}"
49
+ end
50
+
51
+ # 32 lowercase hex characters, the W3C trace-id format; SecureRandom.hex already produces
52
+ # lowercase, which is the only case the spec allows.
53
+ def generate_trace_id
54
+ SecureRandom.hex(16)
55
+ end
56
+
57
+ # 16 lowercase hex characters, the W3C parent-id (span id) format.
58
+ def generate_span_id
59
+ SecureRandom.hex(8)
60
+ end
61
+ end
62
+ end
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.11.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"
@@ -18,10 +20,13 @@ require "forge_ops_tracker/periodic_poller"
18
20
  require "forge_ops_tracker/breadcrumb_buffer"
19
21
  require "forge_ops_tracker/span_buffer"
20
22
  require "forge_ops_tracker/span_queue"
23
+ require "forge_ops_tracker/trace_parent"
24
+ require "forge_ops_tracker/request_state"
21
25
  require "forge_ops_tracker/middleware/session_tracking"
22
26
  require "forge_ops_tracker/middleware/user_context"
23
27
  require "forge_ops_tracker/middleware/breadcrumb_context"
24
28
  require "forge_ops_tracker/middleware/span_tracing"
29
+ require "forge_ops_tracker/middleware/request_context"
25
30
 
26
31
  module ForgeOpsTracker
27
32
  class << self
@@ -29,8 +34,33 @@ module ForgeOpsTracker
29
34
  @configuration ||= Configuration.new
30
35
  end
31
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.
32
40
  def configure
33
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
34
64
  end
35
65
 
36
66
  # Records a named business metric (a signup, a payment, anything a customer wants to name),
@@ -117,6 +147,10 @@ module ForgeOpsTracker
117
147
  end
118
148
 
119
149
  private
150
+ def change_queue
151
+ @change_queue ||= DeliveryQueue.new(configuration, deliver_with: :deliver_change)
152
+ end
153
+
120
154
  def metric_buffer
121
155
  @metric_buffer ||= MetricBuffer.new(configuration)
122
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.11.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
@@ -106,6 +108,7 @@ files:
106
108
  - lib/forge_ops_tracker/integrations/solid_queue.rb
107
109
  - lib/forge_ops_tracker/metric_buffer.rb
108
110
  - lib/forge_ops_tracker/middleware/breadcrumb_context.rb
111
+ - lib/forge_ops_tracker/middleware/request_context.rb
109
112
  - lib/forge_ops_tracker/middleware/session_tracking.rb
110
113
  - lib/forge_ops_tracker/middleware/span_tracing.rb
111
114
  - lib/forge_ops_tracker/middleware/user_context.rb
@@ -114,10 +117,12 @@ files:
114
117
  - lib/forge_ops_tracker/periodic_poller.rb
115
118
  - lib/forge_ops_tracker/pii_scrubber.rb
116
119
  - lib/forge_ops_tracker/railtie.rb
120
+ - lib/forge_ops_tracker/request_state.rb
117
121
  - lib/forge_ops_tracker/session_flusher.rb
118
122
  - lib/forge_ops_tracker/span_buffer.rb
119
123
  - lib/forge_ops_tracker/span_queue.rb
120
124
  - lib/forge_ops_tracker/sql_statement.rb
125
+ - lib/forge_ops_tracker/trace_parent.rb
121
126
  - lib/forge_ops_tracker/version.rb
122
127
  homepage: https://getforgeops.net
123
128
  licenses: