condux 0.1.5 → 0.1.6

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: 72bffbeb8549e50c7defe4c9fb3d18c5eeecd3ca9d02704e05a8bc3733e5eed9
4
- data.tar.gz: 41df087b35b4f1861384e0826e43b8adc7fe42d8c921c59df35339f54ed64ec6
3
+ metadata.gz: ba08155338fd7b32dbe552dadae5337c73090f5f6a57fb0dcee5de53e9f47708
4
+ data.tar.gz: 04ef85c7632b4f681765972446f279f072bd9b4415be3c4261675f8819b2e84b
5
5
  SHA512:
6
- metadata.gz: 97dd3d4ce440838f0e67fed077def7476d2fcd8a61c20e869dc8fc90372a1137575d30ac450c555f074f30133f428deecfa81da2dd370e8f4bedca13f9f2be46
7
- data.tar.gz: 9fd4ce9a8f850b83bdec18d1d70f6539eab33b42aceb636db01689d354ce59fa9d74afa5ef391156df1ad7ae1d03a6b16a09d78611d95f27df437eeb6d656490
6
+ metadata.gz: bfbce466a5f5de39b2e9f1ed77a22f952d84b799a5dd548d7f16012d22b3c103f4bd2c1ed8bb1cad4d8683eabc98cc2666dd4f7b947c852dc0bea515cdfa8153
7
+ data.tar.gz: 8d28840828532b66eabf40de397cc54a58e9e2d745355e0d14babb032958ee409ede759455abf5255a94e5df74731e1cba9dc6be3c45c88287f7c8633d873830
data/README.md CHANGED
@@ -57,6 +57,34 @@ Condux.add_breadcrumb("charge.started", category: "billing")
57
57
 
58
58
  The breadcrumb trail keeps the most recent 30 entries. `Condux.clear_scope` resets everything.
59
59
 
60
+ ### In a server, scope one request at a time
61
+
62
+ Those calls are **process wide** by default, which is right for facts about the deployment and wrong for
63
+ facts about one request: Puma serves requests concurrently, so a bare `set_user` in a controller can
64
+ attach that user to a different request's error. That is worse than reporting no user, because it is
65
+ confidently wrong.
66
+
67
+ `Condux.request_scope` isolates it. Anything set inside belongs to that request alone, layered over the
68
+ process-wide values:
69
+
70
+ ```ruby
71
+ Condux.request_scope do
72
+ Condux.set_user({ "id" => current_user.id }) # this request only
73
+ process(job)
74
+ end
75
+ ```
76
+
77
+ **The Rack middleware below does this for you**, so a `set_user` in a Rails controller is already
78
+ isolated. Call it directly around background jobs, which have the same problem. It uses
79
+ `Thread.current[]`, which is fiber-local in Ruby, so it isolates Puma's threads and Falcon's fibers
80
+ alike.
81
+
82
+ Detail belonging to a single event can skip the scope entirely:
83
+
84
+ ```ruby
85
+ Condux.capture_exception(error, request: { "url" => "/api/sync" }, tags: { "job" => "nightly" })
86
+ ```
87
+
60
88
  ## Rack and Rails
61
89
 
62
90
  ```ruby
@@ -70,6 +98,12 @@ config.middleware.use "Condux::Rack::CaptureExceptions"
70
98
 
71
99
  Uncaught exceptions are reported as unhandled and re-raised, so the app's own error handling still runs.
72
100
 
101
+ **On Rails use `config.middleware.use`, and nothing else.** `use` appends, which puts the middleware at
102
+ the bottom of the stack, inside `ActionDispatch::ShowExceptions`. That position is why it works:
103
+ `ShowExceptions` catches a controller exception and turns it into a 500, so anything above it never sees
104
+ the exception and reports nothing, with no error to tell you. `insert_before`, `insert_after` and
105
+ `unshift` all move it above and break it silently.
106
+
73
107
  ## Develop
74
108
 
75
109
  ```bash
data/lib/condux/client.rb CHANGED
@@ -21,23 +21,32 @@ module Condux
21
21
  @clock = clock || -> { Time.now }
22
22
  end
23
23
 
24
- def capture_exception(error, handled: true)
25
- dispatch(level: Level::ERROR, exception: EventPayload.exception(error, handled: handled))
24
+ # +request+ (url/method/query_string) and +tags+ describe this one event. They are passed here
25
+ # rather than set on the scope because scope state outlives the call: on a server handling requests
26
+ # concurrently, request detail set ambiently attaches to whichever event is captured next, which may
27
+ # belong to a different request.
28
+ def capture_exception(error, handled: true, request: nil, tags: nil)
29
+ dispatch(level: Level::ERROR, exception: EventPayload.exception(error, handled: handled),
30
+ request: request, tags: tags)
26
31
  end
27
32
 
28
- def capture_message(message, level)
29
- dispatch(level: level, message: message)
33
+ def capture_message(message, level, request: nil, tags: nil)
34
+ dispatch(level: level, message: message, request: request, tags: tags)
30
35
  end
31
36
 
32
37
  private
33
38
 
34
- def dispatch(level:, message: nil, exception: nil)
39
+ def dispatch(level:, message: nil, exception: nil, request: nil, tags: nil)
35
40
  event = {
36
41
  "event_id" => SecureRandom.hex(16), # 32 lowercase hex, the Sentry event_id shape
37
42
  "timestamp" => @clock.call.to_f, # epoch seconds, the store convention
38
43
  "platform" => "ruby",
39
44
  "level" => level,
40
45
  }.merge(Scope.fields)
46
+ event["request"] = request if request && !request.empty?
47
+ # Merged over the ambient tags rather than replacing them, so a per-event tag cannot silently drop
48
+ # the deployment-wide ones.
49
+ event["tags"] = (event["tags"] || {}).merge(tags) if tags && !tags.empty?
41
50
  event["environment"] = @environment if @environment
42
51
  event["release"] = @release if @release
43
52
  event["message"] = message if message
data/lib/condux/rack.rb CHANGED
@@ -13,16 +13,46 @@ module Condux
13
13
  #
14
14
  # # Rails (config/application.rb)
15
15
  # config.middleware.use "Condux::Rack::CaptureExceptions"
16
+ #
17
+ # ON RAILS, USE `config.middleware.use` AND NOTHING ELSE.
18
+ #
19
+ # `use` APPENDS, which puts this at the very bottom of the stack, inside ActionDispatch's
20
+ # ShowExceptions and DebugExceptions. That position is the whole reason it works: those two catch a
21
+ # controller exception and turn it into a 500, so anything above them never sees the exception at
22
+ # all and would report nothing, silently. Verified against a real Rails app, where this lands at
23
+ # position 21 with ShowExceptions at 9.
24
+ #
25
+ # So `insert_before`, `insert_after` or `unshift` will move it above them and it will stop reporting
26
+ # with no error to tell you. A future Rails release reordering its own stack could do the same, which
27
+ # is why the position is pinned by a test rather than only described here.
16
28
  class CaptureExceptions
17
29
  def initialize(app)
18
30
  @app = app
19
31
  end
20
32
 
21
33
  def call(env)
22
- @app.call(env)
23
- rescue StandardError => e # report anything the app raises, then re-raise
24
- Condux.capture_exception(e, handled: false)
25
- raise
34
+ # A scope per request, so a set_user in a controller belongs to that request and cannot attach to
35
+ # a concurrent one. Puma reuses threads, which is exactly why this has to be scoped rather than
36
+ # left to process state.
37
+ Condux.request_scope do
38
+ @app.call(env)
39
+ rescue StandardError => e # report anything the app raises, then re-raise
40
+ Condux.capture_exception(e, handled: false, request: self.class.request_fields(env))
41
+ raise
42
+ end
43
+ end
44
+
45
+ # The request, in the Sentry store shape the relay parses.
46
+ #
47
+ # Deliberately only the path, method and query string. Rack puts headers in HTTP_* keys, including
48
+ # HTTP_COOKIE and HTTP_AUTHORIZATION; the relay scrubs sensitive keys at ingest, but not sending
49
+ # credentials at all is the stronger guarantee.
50
+ def self.request_fields(env)
51
+ {
52
+ "url" => env["PATH_INFO"],
53
+ "method" => env["REQUEST_METHOD"],
54
+ "query_string" => env["QUERY_STRING"]
55
+ }.reject { |_key, value| value.nil? || value.empty? }
26
56
  end
27
57
  end
28
58
  end
data/lib/condux/scope.rb CHANGED
@@ -6,10 +6,24 @@ module Condux
6
6
  # it — the first triage questions ("which customer, which plan, what did they do last") answered
7
7
  # without threading anything through capture calls. The relay already scrubs all of these at ingest
8
8
  # and derives the pseudonymous users-affected key from the user fields.
9
+ # There are two layers, and the distinction is the whole point:
10
+ #
11
+ # Process state, set at boot and shared by everything. Right for facts about the deployment.
12
+ # A request scope, active only inside Scope.request. Right for facts about one request.
13
+ #
14
+ # Without the second, set_user in a Rails controller is a cross-request leak: Puma serves requests
15
+ # concurrently, so one request's user would attach to another request's error. That is worse than
16
+ # reporting no user, because it is confidently wrong and points an investigation at the wrong customer.
17
+ # The Rack middleware opens a scope per request, so a set_user in a controller stays in that request.
9
18
  module Scope
10
19
  # Newest trail wins: a long-lived process drops the oldest crumbs rather than growing without bound.
11
20
  MAX_BREADCRUMBS = 30
12
21
 
22
+ # Thread.current[] is FIBER-local in Ruby, unlike thread_variable_get which is thread-local. Fiber
23
+ # local is what this wants: it isolates Puma's threads and also Falcon's fibers, where several
24
+ # requests share one thread and a thread-local would let them see each other's user.
25
+ STATE_KEY = :condux_request_scope
26
+
13
27
  @mutex = Mutex.new
14
28
  @user = nil
15
29
  @tags = {}
@@ -17,13 +31,43 @@ module Condux
17
31
  @breadcrumbs = []
18
32
 
19
33
  class << self
20
- # Attach the signed-in user (id/email/username) to subsequent events; nil clears.
34
+ # Isolate enrichment to one request. Anything set inside is visible only to events captured
35
+ # inside. The Rack middleware wraps every request in this; call it directly around a background
36
+ # job, which has the same problem of many in flight at once.
37
+ def request
38
+ previous = Thread.current[STATE_KEY]
39
+ Thread.current[STATE_KEY] = { user: nil, tags: {}, contexts: {}, breadcrumbs: [] }
40
+ yield
41
+ ensure
42
+ # Always restore. Puma reuses threads, so state left behind is handed to the next request that
43
+ # worker picks up, which is the exact leak this exists to prevent. Restoring the previous value
44
+ # rather than clearing keeps nesting honest.
45
+ Thread.current[STATE_KEY] = previous
46
+ end
47
+
48
+ # Nil when no request is in flight, so writes fall through to process state and a boot-time
49
+ # set_tag behaves exactly as it did before request scopes existed.
50
+ def request_state
51
+ Thread.current[STATE_KEY]
52
+ end
53
+ # Attach the signed-in user (id/email/username) to subsequent events; nil clears. Inside a request
54
+ # scope this applies to that request alone; outside one it is process wide.
21
55
  def user=(user)
22
- @mutex.synchronize { @user = user&.transform_keys(&:to_s) }
56
+ value = user&.transform_keys(&:to_s)
57
+ state = request_state
58
+ return state[:user] = value if state
59
+
60
+ @mutex.synchronize { @user = value }
23
61
  end
24
62
 
25
63
  # Attach a tag to subsequent events; a nil value removes it.
26
64
  def set_tag(key, value)
65
+ state = request_state
66
+ if state
67
+ value.nil? ? state[:tags].delete(key.to_s) : state[:tags][key.to_s] = value
68
+ return
69
+ end
70
+
27
71
  @mutex.synchronize do
28
72
  if value.nil?
29
73
  @tags.delete(key.to_s)
@@ -35,11 +79,18 @@ module Condux
35
79
 
36
80
  # Attach a named context object to subsequent events; nil removes it.
37
81
  def set_context(name, context)
82
+ value = context&.transform_keys(&:to_s)
83
+ state = request_state
84
+ if state
85
+ value.nil? ? state[:contexts].delete(name.to_s) : state[:contexts][name.to_s] = value
86
+ return
87
+ end
88
+
38
89
  @mutex.synchronize do
39
- if context.nil?
90
+ if value.nil?
40
91
  @contexts.delete(name.to_s)
41
92
  else
42
- @contexts[name.to_s] = context.transform_keys(&:to_s)
93
+ @contexts[name.to_s] = value
43
94
  end
44
95
  end
45
96
  end
@@ -52,14 +103,27 @@ module Condux
52
103
  crumb["type"] = type if type
53
104
  crumb["data"] = data if data
54
105
 
106
+ state = request_state
107
+ if state
108
+ state[:breadcrumbs] << crumb
109
+ state[:breadcrumbs].shift while state[:breadcrumbs].length > MAX_BREADCRUMBS
110
+ return
111
+ end
112
+
55
113
  @mutex.synchronize do
56
114
  @breadcrumbs << crumb
57
115
  @breadcrumbs.shift while @breadcrumbs.length > MAX_BREADCRUMBS
58
116
  end
59
117
  end
60
118
 
61
- # Reset all ambient state (tests, or a full sign-out).
119
+ # Reset all ambient state (tests, or a full sign-out). Clears the request scope when one is active.
62
120
  def clear
121
+ state = request_state
122
+ if state
123
+ state.replace(user: nil, tags: {}, contexts: {}, breadcrumbs: [])
124
+ return
125
+ end
126
+
63
127
  @mutex.synchronize do
64
128
  @user = nil
65
129
  @tags = {}
@@ -70,15 +134,28 @@ module Condux
70
134
 
71
135
  # The scope's contribution to an event, holding only the keys that are actually set so an
72
136
  # unenriched event keeps its exact wire shape. Breadcrumbs use the Sentry {"values" => []} envelope.
137
+ #
138
+ # The request scope layers OVER the process state rather than replacing it, so a request keeps the
139
+ # deployment-wide tags while overriding the ones it sets itself.
73
140
  def fields
74
- @mutex.synchronize do
75
- fields = {}
76
- fields["user"] = @user.dup if @user
77
- fields["tags"] = @tags.dup unless @tags.empty?
78
- fields["contexts"] = @contexts.dup unless @contexts.empty?
79
- fields["breadcrumbs"] = { "values" => @breadcrumbs.dup } unless @breadcrumbs.empty?
80
- fields
141
+ state = request_state || {}
142
+ process = @mutex.synchronize do
143
+ { user: @user&.dup, tags: @tags.dup, contexts: @contexts.dup, breadcrumbs: @breadcrumbs.dup }
81
144
  end
145
+
146
+ user = state[:user] || process[:user]
147
+ tags = process[:tags].merge(state[:tags] || {})
148
+ contexts = process[:contexts].merge(state[:contexts] || {})
149
+ # Concatenated, not merged: the trail is a sequence, and the process-level crumbs genuinely
150
+ # happened before the ones recorded during the request.
151
+ breadcrumbs = (process[:breadcrumbs] + (state[:breadcrumbs] || [])).last(MAX_BREADCRUMBS)
152
+
153
+ fields = {}
154
+ fields["user"] = user.dup if user
155
+ fields["tags"] = tags unless tags.empty?
156
+ fields["contexts"] = contexts unless contexts.empty?
157
+ fields["breadcrumbs"] = { "values" => breadcrumbs } unless breadcrumbs.empty?
158
+ fields
82
159
  end
83
160
  end
84
161
  end
data/lib/condux.rb CHANGED
@@ -24,11 +24,11 @@ module Condux
24
24
 
25
25
  # Report an exception as an error-level event, with its stack trace. Never raises on delivery failure.
26
26
  # Pass handled: false for an uncaught exception (a framework integration does this).
27
- def capture_exception(error, handled: true)
27
+ def capture_exception(error, handled: true, request: nil, tags: nil)
28
28
  client = active_client
29
29
  return not_initialized unless client
30
30
 
31
- client.capture_exception(error, handled: handled)
31
+ client.capture_exception(error, handled: handled, request: request, tags: tags)
32
32
  end
33
33
 
34
34
  # Report a bare message event at the given level (default info).
@@ -39,6 +39,12 @@ module Condux
39
39
  client.capture_message(message, level)
40
40
  end
41
41
 
42
+ # Isolate enrichment to one request, so a set_user inside it cannot attach to a concurrent request.
43
+ # The Rack middleware does this for every request; call it directly around a background job.
44
+ def request_scope(&block)
45
+ Scope.request(&block)
46
+ end
47
+
42
48
  # Attach the signed-in user (id/email/username) to subsequent events; nil clears.
43
49
  def set_user(user)
44
50
  Scope.user = user
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: condux
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Condux
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'The Condux SDK for Ruby: report errors to a Condux relay with a resilient,
14
14
  never-raising transport. Zero runtime dependencies (standard library only).'