forge_ops_tracker 0.11.0 → 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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +82 -0
- data/lib/forge_ops_tracker/configuration.rb +32 -0
- data/lib/forge_ops_tracker/error_subscriber.rb +26 -4
- data/lib/forge_ops_tracker/event_builder.rb +17 -4
- data/lib/forge_ops_tracker/integrations/net_http.rb +27 -2
- data/lib/forge_ops_tracker/middleware/request_context.rb +90 -0
- data/lib/forge_ops_tracker/middleware/span_tracing.rb +25 -4
- data/lib/forge_ops_tracker/performance_instrumentation.rb +31 -0
- data/lib/forge_ops_tracker/railtie.rb +27 -2
- data/lib/forge_ops_tracker/request_state.rb +69 -0
- data/lib/forge_ops_tracker/span_buffer.rb +26 -11
- data/lib/forge_ops_tracker/trace_parent.rb +62 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70707edca2368561ce9b785b91dc9e8a26280071ec3db9ed34fa71db6c402fcf
|
|
4
|
+
data.tar.gz: a6fc1013df0404e46e0683749f2cedd688ac30273bda4ac113a7dd6334a19430
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 420ff144de5e84636348c245fef836cf240c94fe6bccb49217f8d51455a3c08ce81c18dadcf7260ccc46f35dd7b2470d809b1eecd92d9bea70e17ecf149f1a78
|
|
7
|
+
data.tar.gz: 74fc60b3d7451827618e0d63deef8cd6563d29eab144c8556bc912e91bbb4a7c8467110ae25940a7e4a7457db8718cad8b763ff752311014ed325b22ce5c13b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
3
34
|
## 0.11.0
|
|
4
35
|
|
|
5
36
|
- 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,61 @@ 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
|
+
|
|
332
414
|
## Custom metrics
|
|
333
415
|
|
|
334
416
|
Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
|
|
@@ -17,6 +17,7 @@ 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
|
|
20
21
|
|
|
21
22
|
def initialize
|
|
22
23
|
@dsn = ENV["FORGE_OPS_DSN"]
|
|
@@ -124,6 +125,37 @@ module ForgeOpsTracker
|
|
|
124
125
|
# PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
|
|
125
126
|
# many requests, this describes a single one being outright slow.
|
|
126
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
|
|
127
159
|
end
|
|
128
160
|
|
|
129
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
# structured fields this gem or the host app sets
|
|
54
|
-
# or its context could accidentally spill sensitive
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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 =
|
|
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):
|
|
46
|
-
#
|
|
47
|
-
# nothing left on it to read back as "the
|
|
48
|
-
#
|
|
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 ?
|
|
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
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -18,10 +18,13 @@ require "forge_ops_tracker/periodic_poller"
|
|
|
18
18
|
require "forge_ops_tracker/breadcrumb_buffer"
|
|
19
19
|
require "forge_ops_tracker/span_buffer"
|
|
20
20
|
require "forge_ops_tracker/span_queue"
|
|
21
|
+
require "forge_ops_tracker/trace_parent"
|
|
22
|
+
require "forge_ops_tracker/request_state"
|
|
21
23
|
require "forge_ops_tracker/middleware/session_tracking"
|
|
22
24
|
require "forge_ops_tracker/middleware/user_context"
|
|
23
25
|
require "forge_ops_tracker/middleware/breadcrumb_context"
|
|
24
26
|
require "forge_ops_tracker/middleware/span_tracing"
|
|
27
|
+
require "forge_ops_tracker/middleware/request_context"
|
|
25
28
|
|
|
26
29
|
module ForgeOpsTracker
|
|
27
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.
|
|
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,10 +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
|
|
120
122
|
- lib/forge_ops_tracker/sql_statement.rb
|
|
123
|
+
- lib/forge_ops_tracker/trace_parent.rb
|
|
121
124
|
- lib/forge_ops_tracker/version.rb
|
|
122
125
|
homepage: https://getforgeops.net
|
|
123
126
|
licenses:
|