forge_ops_tracker 0.10.2 → 0.12.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: 9b32a031e24fb613c219aee3bf01f0517043b05b4d1788d7ece456096d7561a0
4
- data.tar.gz: 39c7f16920425d01862607b6d3f85b957c694ab009ce8ffd010f33714632a3a9
3
+ metadata.gz: 70707edca2368561ce9b785b91dc9e8a26280071ec3db9ed34fa71db6c402fcf
4
+ data.tar.gz: a6fc1013df0404e46e0683749f2cedd688ac30273bda4ac113a7dd6334a19430
5
5
  SHA512:
6
- metadata.gz: 315fb4765179a298da43dd2beabe68970f257dee7b4b1f3938bcc6efa72ac403b8dbb0e39b8d16dfcaf19b76696aed961270dc88fd92731b05ae1610b3d4a223
7
- data.tar.gz: f2907bae8015b4093346a30369cc3da38c8d36f4198ae4935cb1e2d2cea2e6baed44d5fcab4a8278029f228353d991275316fd54a9e08bd3116519b529f09159
6
+ metadata.gz: 420ff144de5e84636348c245fef836cf240c94fe6bccb49217f8d51455a3c08ce81c18dadcf7260ccc46f35dd7b2470d809b1eecd92d9bea70e17ecf149f1a78
7
+ data.tar.gz: 74fc60b3d7451827618e0d63deef8cd6563d29eab144c8556bc912e91bbb4a7c8467110ae25940a7e4a7457db8718cad8b763ff752311014ed325b22ce5c13b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ - Every error event reported during a request now says where it happened: `transaction_name` (the
6
+ same `CheckoutsController#create` name performance samples and traces already use), `endpoint`
7
+ (the HTTP method plus the route as declared in your routes file, e.g. `POST /checkout` or
8
+ `GET /orders/:id`, never the literal path, so no ids or tokens; falls back to
9
+ `POST CheckoutsController#create` before Rails 7.1), and `trace_id` (the request's W3C trace id).
10
+ All three are set as soon as the controller starts handling the request, so an error reported
11
+ from inside the action with `Rails.error.handle`/`record` already has them. Left out entirely
12
+ outside a request (a background job, a console session). Structured fields, so they're never
13
+ PII-scrubbed.
14
+ - Unhandled request errors keep their context. Rails 8.1 reports an unhandled request exception
15
+ from its executor, outside this gem's middleware, after the gem had already cleared its
16
+ per-request state, so the affected user and the breadcrumb trail were missing on exactly those
17
+ errors. A new innermost middleware, `ForgeOpsTracker::Middleware::RequestContext`, now copies the
18
+ user, breadcrumbs, transaction name, endpoint, and trace id onto the exception on its way out
19
+ (re-raising it unchanged), and the error subscriber falls back to that copy.
20
+ - Distributed tracing across services, using the W3C Trace Context standard. A request that
21
+ arrives with a valid `traceparent` header continues that trace instead of starting its own, and
22
+ its root span points at the caller's span. Every outbound `Net::HTTP` call made during a request
23
+ now sends a `traceparent` header whose parent id is that call's own span, so the next service
24
+ (any service that understands the standard, not only ones running this gem) can continue the
25
+ trace. A `traceparent` you set yourself is never overwritten. New `config.propagate_traces`
26
+ (default true) and `config.trace_propagation_targets` (default nil, meaning every host; or an
27
+ array of host Strings, each matching that host and its subdomains, and/or Regexps) control where
28
+ the header goes. A trace id is created for every request even with `track_tracing` off, since it
29
+ is also what links an error to errors in other services.
30
+ - An errored request's trace is always sent, however fast it was: an unhandled exception, or any
31
+ error reported through `Rails.error` while the request was running. Previously only requests
32
+ slower than `trace_capture_threshold_ms` were sent. Fast, successful requests are unchanged.
33
+
34
+ ## 0.11.0
35
+
36
+ - Database errors now say where to look. When an error comes from a database call
37
+ (`ActiveRecord::StatementInvalid`, or an exception of yours raised from one), the event carries the
38
+ names of the stored procedure or function and the tables or views its SQL touched, so ForgeOps can
39
+ show them on the issue. On by default (`config.capture_sql_objects`); names are identifiers, never
40
+ values. New opt-in `config.capture_sql_statement` (default false) also sends the statement itself,
41
+ with every string and number replaced by `?`. Each project has its own server-side setting that
42
+ can stop the statement being stored regardless of this flag; the names are still kept.
43
+
3
44
  ## 0.10.2
4
45
 
5
46
  - Documentation only: the README and package description now describe ForgeOps as a hosted service, link to getforgeops.net, and show the real host in the connection string example instead of a placeholder. No code changes.
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,
@@ -187,6 +214,29 @@ ForgeOpsTracker.configure do |config|
187
214
  end
188
215
  ```
189
216
 
217
+ ## Database errors
218
+
219
+ When an error comes from a database call (`ActiveRecord::StatementInvalid`, or your own exception
220
+ raised from one), the event carries the names of the stored procedure or function and the tables or
221
+ views its SQL touched, so the issue tells you where to start looking. This is on by default and
222
+ sends identifiers only, never values. A view and a table are written the same way in SQL, so both
223
+ show as tables/views; the database's own error message usually settles which it was.
224
+
225
+ To also send the SQL statement itself, opt in. Every string and number is replaced by `?` before it
226
+ leaves your process (`WHERE email = 'a@b.co' AND id = 42` is sent as `WHERE email = ? AND id = ?`),
227
+ and ForgeOps masks it again on arrival:
228
+
229
+ ```ruby
230
+ ForgeOpsTracker.configure do |config|
231
+ config.capture_sql_statement = true # default false
232
+ config.capture_sql_objects = false # default true; false stops even the names
233
+ end
234
+ ```
235
+
236
+ Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off
237
+ there and the statement is never stored for that project, whatever this flag says; the names are
238
+ still kept.
239
+
190
240
  ## Session tracking (release health)
191
241
 
192
242
  By default, every request is counted as a session: crash-free unless an unhandled exception
@@ -306,6 +356,61 @@ Requires a ForgeOps plan that includes performance monitoring, the same plan fea
306
356
  monitoring itself already requires; on a plan that doesn't, the periodic flushes are simply
307
357
  rejected server-side and dropped, exactly like any other delivery failure.
308
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
+
309
414
  ## Custom metrics
310
415
 
311
416
  Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
@@ -8,6 +8,7 @@ module ForgeOpsTracker
8
8
  attr_accessor :dsn, :environment, :release, :server_name, :app_root, :logger
9
9
  attr_accessor :enabled_environments, :queue_size, :open_timeout, :read_timeout, :scrub_pii
10
10
  attr_accessor :capture_source_context
11
+ attr_accessor :capture_sql_objects, :capture_sql_statement
11
12
  attr_accessor :track_sessions, :session_flush_interval
12
13
  attr_accessor :track_performance, :performance_flush_interval
13
14
  attr_accessor :track_current_user
@@ -16,6 +17,7 @@ module ForgeOpsTracker
16
17
  attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
17
18
  attr_accessor :gauge_poll_interval
18
19
  attr_accessor :track_tracing, :trace_capture_threshold_ms
20
+ attr_accessor :propagate_traces, :trace_propagation_targets
19
21
 
20
22
  def initialize
21
23
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -45,6 +47,15 @@ module ForgeOpsTracker
45
47
  # off here too if this host app never wants that disk read attempted
46
48
  # in the first place.
47
49
  @capture_source_context = true
50
+ # When an error comes from a database call (ActiveRecord::StatementInvalid and anything that
51
+ # wraps one), send the names of the stored procedure, table and view its SQL touched, so an
52
+ # issue says where to start looking. Names are identifiers, never values, which is why this
53
+ # defaults on. capture_sql_statement is the separate, opt-in step of also sending the
54
+ # statement itself, with every string and number replaced by "?"; off by default because
55
+ # even a masked statement describes the customer's schema, and ForgeOps' own per-project
56
+ # setting is what durably governs whether the server stores it. See SqlStatement.
57
+ @capture_sql_objects = true
58
+ @capture_sql_statement = false
48
59
  # Auto-instruments every request the moment the gem loads, the same "on unless you turn it
49
60
  # off" default error tracking itself already has; nothing else in this gem is opt-in. See
50
61
  # ForgeOpsTracker::Middleware::SessionTracking for what this actually wraps.
@@ -114,6 +125,37 @@ module ForgeOpsTracker
114
125
  # PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
115
126
  # many requests, this describes a single one being outright slow.
116
127
  @trace_capture_threshold_ms = 1_000
128
+ # Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
129
+ # Net::HTTP request made during a request, so the service on the other end can continue this
130
+ # request's trace instead of starting its own (see Integrations::NetHTTP). On by default, the
131
+ # same as every other automatic behavior here, and independent of track_tracing: the header
132
+ # carries the trace id that links an error here to an error there, which is useful with or
133
+ # without spans. Never overwrites a traceparent the request already has.
134
+ @propagate_traces = true
135
+ # Which outbound hosts get that header. nil (the default) means every host. Otherwise an
136
+ # Array of Strings and/or Regexps: a String matches that exact host or any subdomain of it
137
+ # ("example.com" matches "example.com" and "api.example.com", but not "badexample.com"), a
138
+ # Regexp is matched against the host. Worth narrowing for an app that calls third-party APIs
139
+ # which reject unknown headers, or that shouldn't learn this app's trace ids at all.
140
+ @trace_propagation_targets = nil
141
+ end
142
+
143
+ # Whether an outbound request to host should carry a traceparent header; see
144
+ # propagate_traces/trace_propagation_targets above. Case-insensitive, since hostnames are.
145
+ def propagate_trace_to?(host)
146
+ return false unless propagate_traces
147
+ return true if trace_propagation_targets.nil?
148
+
149
+ host = host.to_s.downcase
150
+ Array(trace_propagation_targets).any? do |target|
151
+ case target
152
+ when Regexp
153
+ target.match?(host)
154
+ else
155
+ target = target.to_s.downcase.delete_prefix(".")
156
+ !target.empty? && (host == target || host.end_with?(".#{target}"))
157
+ end
158
+ end
117
159
  end
118
160
 
119
161
  def api_key
@@ -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,15 +50,21 @@ 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
56
+ attach_sql(payload, error)
45
57
  scrub(payload)
46
58
  end
47
59
 
48
60
  private
49
61
  attr_reader :configuration
50
62
 
51
- # exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone;
52
- # structured fields this gem or the host app sets deliberately, not free text an exception
53
- # 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
54
68
  # deliberate exemption, not an oversight: the server's own PiiScrubber-equivalent would
55
69
  # otherwise redact the exact email address this field exists to carry. breadcrumbs is *not*
56
70
  # exempt, unlike user: console-style/query/request trail entries are exactly the kind of free
@@ -66,10 +80,25 @@ module ForgeOpsTracker
66
80
  context: PiiScrubber.scrub(payload[:context]),
67
81
  tags: PiiScrubber.scrub(payload[:tags])
68
82
  )
83
+ scrubbed[:sql_statement] = PiiScrubber.scrub(payload[:sql_statement]) if payload.key?(:sql_statement)
69
84
  scrubbed[:breadcrumbs] = PiiScrubber.scrub(payload[:breadcrumbs]) if payload.key?(:breadcrumbs)
70
85
  scrubbed
71
86
  end
72
87
 
88
+ # See SqlStatement for what's read off the error and how it's masked. The statement itself
89
+ # only goes out when capture_sql_statement is on; the extracted names go out on their own
90
+ # (capture_sql_objects) so an issue can still name the procedure or view involved.
91
+ def attach_sql(payload, error)
92
+ return unless configuration.capture_sql_objects || configuration.capture_sql_statement
93
+
94
+ masked = SqlStatement.mask(SqlStatement.find_in(error))
95
+ return unless masked
96
+
97
+ objects = SqlStatement.objects(masked)
98
+ payload[:sql_objects] = objects if objects && configuration.capture_sql_objects
99
+ payload[:sql_statement] = masked if configuration.capture_sql_statement
100
+ end
101
+
73
102
  def backtrace_frames(error)
74
103
  Array(error.backtrace).first(MAX_FRAMES).filter_map { |line| parse_backtrace_line(line) }
75
104
  end
@@ -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(
@@ -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,95 @@
1
+ module ForgeOpsTracker
2
+ # Finds the SQL behind a database error and reduces it to something safe to send: the names of
3
+ # the stored procedures, tables and views it touched, and (only if configuration.
4
+ # capture_sql_statement is on) the statement itself with every string and number replaced by
5
+ # "?". Ported from the server's own SqlStatementMasker/SqlObjectExtractor, same rules; the
6
+ # server applies them again on arrival, so a difference here can only ever mean less is
7
+ # masked client-side, never that something unmasked gets stored.
8
+ #
9
+ # Deliberately a single pass over a few patterns, not a SQL parser; see the server's own
10
+ # comments for the reasoning behind each choice, which apply unchanged here.
11
+ module SqlStatement
12
+ MASK = "?".freeze
13
+ MAX_LENGTH = 4_000
14
+ MAX_NAMES = 10
15
+ MAX_NAME_LENGTH = 200
16
+ MAX_CAUSE_DEPTH = 5
17
+
18
+ LITERAL = /
19
+ '(?:[^']|'')*(?:'|\z) # string literal, or one cut off by truncation
20
+ | (?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z) # dollar-quoted string
21
+ | (?<![\w$.])\d+(?:\.\d+)?(?!\w) # number, not part of an identifier or placeholder
22
+ /mx
23
+
24
+ PART = /(?:[\w$#@]+|"[^"]+"|\[[^\]]+\]|`[^`]+`)/
25
+ NAME = /#{PART}(?:\.#{PART})*/
26
+ OPERATIONS = %w[SELECT INSERT UPDATE DELETE MERGE WITH CALL EXEC EXECUTE CREATE ALTER DROP TRUNCATE].freeze
27
+ PROCEDURE_CALL = /\b(?:CALL|EXEC(?:UTE)?|PERFORM)\s+(?!IMMEDIATE\b|FUNCTION\b|PROCEDURE\b)(#{NAME})/i
28
+ RELATION = /\b(FROM|JOIN|INTO|UPDATE|TABLE)\s+(#{NAME})(\s*\()?/i
29
+ SELECT_FUNCTION = /\A\s*SELECT\s+(#{NAME})\s*\(/i
30
+ BUILTINS = %w[
31
+ count sum min max avg now coalesce nullif lower upper length concat cast date_trunc
32
+ current_timestamp current_date row_number rank json_build_object json_agg array_agg
33
+ ].freeze
34
+ FROM_INSIDE_FUNCTION = /\b(?:EXTRACT|SUBSTRING|TRIM|OVERLAY)\s*\([^()]*\)/i
35
+ KEYWORDS_NOT_NAMES = %w[select set values where lateral only unnest generate_series].freeze
36
+
37
+ module_function
38
+
39
+ # The raw statement off the error itself or, for an app that wraps a database error in its
40
+ # own exception, off whatever it was raised from. ActiveRecord::StatementInvalid#sql is the
41
+ # one source this reads; nothing else in Ruby exposes the statement on the exception.
42
+ def find_in(error)
43
+ depth = 0
44
+ while error && depth < MAX_CAUSE_DEPTH
45
+ sql = error.sql if error.respond_to?(:sql)
46
+ return sql.to_s if sql.is_a?(String) && !sql.strip.empty?
47
+
48
+ error = error.cause
49
+ depth += 1
50
+ end
51
+ nil
52
+ end
53
+
54
+ def mask(statement)
55
+ return nil if statement.nil? || statement.strip.empty?
56
+
57
+ masked = statement.gsub(LITERAL, MASK)
58
+ masked.length > MAX_LENGTH ? "#{masked[0, MAX_LENGTH]}..." : masked
59
+ end
60
+
61
+ # Takes an already-masked statement (so a keyword inside a string value can't be mistaken for
62
+ # SQL). Returns nil when nothing recognizable was found.
63
+ def objects(masked)
64
+ return nil if masked.nil? || masked.strip.empty?
65
+
66
+ sql = masked.gsub(FROM_INSIDE_FUNCTION, " ")
67
+ procedures = sql.scan(PROCEDURE_CALL).flatten
68
+ relations = []
69
+
70
+ sql.scan(RELATION) do |keyword, name, paren|
71
+ next if KEYWORDS_NOT_NAMES.include?(name.downcase)
72
+
73
+ function_call = paren && %w[FROM JOIN].include?(keyword.upcase)
74
+ (function_call ? procedures : relations) << name
75
+ end
76
+
77
+ function = sql[SELECT_FUNCTION, 1]
78
+ procedures << function if function && !BUILTINS.include?(function.downcase) && !sql.match?(/\bFROM\b/i)
79
+
80
+ operation = sql[/\A\s*(\w+)/, 1].to_s.upcase
81
+ result = {
82
+ operation: (operation if OPERATIONS.include?(operation)),
83
+ procedures: clean(procedures),
84
+ relations: clean(relations)
85
+ }.compact
86
+ result[:procedures].empty? && result[:relations].empty? && !result.key?(:operation) ? nil : result
87
+ end
88
+
89
+ def clean(names)
90
+ names.map { |name| name.to_s.strip[0, MAX_NAME_LENGTH] }
91
+ .select { |name| name.match?(/\A#{NAME}\z/) }
92
+ .uniq.first(MAX_NAMES)
93
+ end
94
+ end
95
+ 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.10.2"
2
+ VERSION = "0.12.0"
3
3
  end
@@ -2,6 +2,7 @@ require "securerandom"
2
2
  require "forge_ops_tracker/version"
3
3
  require "forge_ops_tracker/configuration"
4
4
  require "forge_ops_tracker/pii_scrubber"
5
+ require "forge_ops_tracker/sql_statement"
5
6
  require "forge_ops_tracker/event_builder"
6
7
  require "forge_ops_tracker/client"
7
8
  require "forge_ops_tracker/delivery_queue"
@@ -17,10 +18,13 @@ require "forge_ops_tracker/periodic_poller"
17
18
  require "forge_ops_tracker/breadcrumb_buffer"
18
19
  require "forge_ops_tracker/span_buffer"
19
20
  require "forge_ops_tracker/span_queue"
21
+ require "forge_ops_tracker/trace_parent"
22
+ require "forge_ops_tracker/request_state"
20
23
  require "forge_ops_tracker/middleware/session_tracking"
21
24
  require "forge_ops_tracker/middleware/user_context"
22
25
  require "forge_ops_tracker/middleware/breadcrumb_context"
23
26
  require "forge_ops_tracker/middleware/span_tracing"
27
+ require "forge_ops_tracker/middleware/request_context"
24
28
 
25
29
  module ForgeOpsTracker
26
30
  class << self
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.10.2
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -106,6 +106,7 @@ files:
106
106
  - lib/forge_ops_tracker/integrations/solid_queue.rb
107
107
  - lib/forge_ops_tracker/metric_buffer.rb
108
108
  - lib/forge_ops_tracker/middleware/breadcrumb_context.rb
109
+ - lib/forge_ops_tracker/middleware/request_context.rb
109
110
  - lib/forge_ops_tracker/middleware/session_tracking.rb
110
111
  - lib/forge_ops_tracker/middleware/span_tracing.rb
111
112
  - lib/forge_ops_tracker/middleware/user_context.rb
@@ -114,9 +115,12 @@ files:
114
115
  - lib/forge_ops_tracker/periodic_poller.rb
115
116
  - lib/forge_ops_tracker/pii_scrubber.rb
116
117
  - lib/forge_ops_tracker/railtie.rb
118
+ - lib/forge_ops_tracker/request_state.rb
117
119
  - lib/forge_ops_tracker/session_flusher.rb
118
120
  - lib/forge_ops_tracker/span_buffer.rb
119
121
  - lib/forge_ops_tracker/span_queue.rb
122
+ - lib/forge_ops_tracker/sql_statement.rb
123
+ - lib/forge_ops_tracker/trace_parent.rb
120
124
  - lib/forge_ops_tracker/version.rb
121
125
  homepage: https://getforgeops.net
122
126
  licenses: