condux 0.1.5 → 0.1.7
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/README.md +34 -0
- data/lib/condux/client.rb +14 -5
- data/lib/condux/rack.rb +34 -4
- data/lib/condux/scope.rb +89 -12
- data/lib/condux.rb +8 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '095069e5d14a6e32c481494fb6927c19441f56cc0a9cdc9dd6311acd9ef7e7c9'
|
|
4
|
+
data.tar.gz: 1b3db61a8413e8fab802e39c13317b77c3418277b7f9a5b1c083b762cc64ba86
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b12b5ffdee576bb9879191e5ba68748ce9a7b9cb81843422ce10b567d15776616d156cfcc1de0b3b8afb1caef5fec53501316d8d8a44e960ad78aa8f45eef9c
|
|
7
|
+
data.tar.gz: 5ea29b8ae01b6f994d62b3b5af926d66a41878ae444f927fba82f05ab7d3cf03ab1f3c5db3e6a1ab8a158be2cfef7ec6132ac581f180d1f6859615260fbe5f37
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
90
|
+
if value.nil?
|
|
40
91
|
@contexts.delete(name.to_s)
|
|
41
92
|
else
|
|
42
|
-
@contexts[name.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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
4
|
+
version: 0.1.7
|
|
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-
|
|
11
|
+
date: 2026-08-16 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).'
|