condux 0.1.4 → 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 +4 -4
- data/README.md +78 -2
- data/exe/condux +6 -0
- data/lib/condux/client.rb +16 -6
- data/lib/condux/dsn.rb +3 -2
- data/lib/condux/rack.rb +34 -4
- data/lib/condux/scope.rb +162 -0
- data/lib/condux/test_event.rb +56 -0
- data/lib/condux.rb +59 -7
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba08155338fd7b32dbe552dadae5337c73090f5f6a57fb0dcee5de53e9f47708
|
|
4
|
+
data.tar.gz: 04ef85c7632b4f681765972446f279f072bd9b4415be3c4261675f8819b2e84b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfbce466a5f5de39b2e9f1ed77a22f952d84b799a5dd548d7f16012d22b3c103f4bd2c1ed8bb1cad4d8683eabc98cc2666dd4f7b947c852dc0bea515cdfa8153
|
|
7
|
+
data.tar.gz: 8d28840828532b66eabf40de397cc54a58e9e2d745355e0d14babb032958ee409ede759455abf5255a94e5df74731e1cba9dc6be3c45c88287f7c8633d873830
|
data/README.md
CHANGED
|
@@ -4,7 +4,10 @@ Report errors from a Ruby app to a Condux relay. Emits the Sentry "store" wire s
|
|
|
4
4
|
normalizes it exactly like an official Sentry SDK — point it at a project DSN and it works.
|
|
5
5
|
|
|
6
6
|
Delivery is resilient (429 / 5xx / network failures retry with backoff, honoring `Retry-After`) and
|
|
7
|
-
**never raises** — a failed send returns a `SendResult`, it does not crash the caller.
|
|
7
|
+
**never raises** — a failed send returns a `SendResult`, it does not crash the caller. That holds even
|
|
8
|
+
if `init` was never called: capture warns once and drops the event, because the Rack middleware below
|
|
9
|
+
reports from inside a `rescue` and an SDK that raised there would replace your application's exception
|
|
10
|
+
with its own. A malformed DSN is refused by `init` instead, where it is a developer-time mistake.
|
|
8
11
|
|
|
9
12
|
## Usage
|
|
10
13
|
|
|
@@ -28,10 +31,83 @@ end
|
|
|
28
31
|
Condux.capture_message("cache miss storm", level: Condux::Level::WARNING)
|
|
29
32
|
```
|
|
30
33
|
|
|
34
|
+
## Verify your setup
|
|
35
|
+
|
|
36
|
+
Silence is what a broken error monitor and a healthy app look like from the outside, so prove the
|
|
37
|
+
pipeline once:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
CONDUX_DSN="https://<key>@ingest.condux.ai/<projectId>" bundle exec condux test-event
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Exit code 0 means delivered (the message appears as an info-level issue), 1 means delivery failed and
|
|
44
|
+
prints why, 2 means the DSN was missing or malformed.
|
|
45
|
+
|
|
46
|
+
## Enrichment
|
|
47
|
+
|
|
48
|
+
Attach the ambient facts triage always needs. Every subsequent event carries them, so nothing has to be
|
|
49
|
+
threaded through capture calls:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Condux.set_user({ "id" => "1042", "email" => "dev@example.com" }) # nil clears it (sign-out)
|
|
53
|
+
Condux.set_tag("plan", "team") # nil removes the tag
|
|
54
|
+
Condux.set_context("job", { "queue" => "billing", "attempt" => 3 })
|
|
55
|
+
Condux.add_breadcrumb("charge.started", category: "billing")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The breadcrumb trail keeps the most recent 30 entries. `Condux.clear_scope` resets everything.
|
|
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
|
+
|
|
88
|
+
## Rack and Rails
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config.ru
|
|
92
|
+
require "condux/rack"
|
|
93
|
+
use Condux::Rack::CaptureExceptions
|
|
94
|
+
|
|
95
|
+
# Rails (config/application.rb)
|
|
96
|
+
config.middleware.use "Condux::Rack::CaptureExceptions"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Uncaught exceptions are reported as unhandled and re-raised, so the app's own error handling still runs.
|
|
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
|
+
|
|
31
107
|
## Develop
|
|
32
108
|
|
|
33
109
|
```bash
|
|
34
|
-
ruby -Ilib -Itest
|
|
110
|
+
for f in test/*.rb; do ruby -Ilib -Itest "$f"; done
|
|
35
111
|
```
|
|
36
112
|
|
|
37
113
|
Zero runtime dependencies (standard library only). The transport, sleep, and clock are injectable
|
data/exe/condux
ADDED
data/lib/condux/client.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "level"
|
|
|
6
6
|
require_relative "dsn"
|
|
7
7
|
require_relative "event_payload"
|
|
8
8
|
require_relative "event_transport"
|
|
9
|
+
require_relative "scope"
|
|
9
10
|
|
|
10
11
|
module Condux
|
|
11
12
|
# A configured reporter. Cheap to hold for the process lifetime; safe for concurrent use.
|
|
@@ -20,23 +21,32 @@ module Condux
|
|
|
20
21
|
@clock = clock || -> { Time.now }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
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)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
|
-
def capture_message(message, level)
|
|
28
|
-
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)
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
private
|
|
32
38
|
|
|
33
|
-
def dispatch(level:, message: nil, exception: nil)
|
|
39
|
+
def dispatch(level:, message: nil, exception: nil, request: nil, tags: nil)
|
|
34
40
|
event = {
|
|
35
41
|
"event_id" => SecureRandom.hex(16), # 32 lowercase hex, the Sentry event_id shape
|
|
36
42
|
"timestamp" => @clock.call.to_f, # epoch seconds, the store convention
|
|
37
43
|
"platform" => "ruby",
|
|
38
44
|
"level" => level,
|
|
39
|
-
}
|
|
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?
|
|
40
50
|
event["environment"] = @environment if @environment
|
|
41
51
|
event["release"] = @release if @release
|
|
42
52
|
event["message"] = message if message
|
data/lib/condux/dsn.rb
CHANGED
|
@@ -9,13 +9,14 @@ module Condux
|
|
|
9
9
|
|
|
10
10
|
def self.parse(dsn)
|
|
11
11
|
uri = URI(dsn)
|
|
12
|
-
|
|
12
|
+
project_id = uri.path.to_s.sub(%r{\A/}, "")
|
|
13
|
+
if uri.user.nil? || uri.user.empty? || uri.host.nil? || project_id.empty?
|
|
13
14
|
raise ArgumentError, "Condux: DSN must be scheme://<key>@<host>/<projectId>"
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
endpoint = "#{uri.scheme}://#{uri.host}"
|
|
17
18
|
endpoint += ":#{uri.port}" if uri.port && ![80, 443].include?(uri.port)
|
|
18
|
-
new(endpoint,
|
|
19
|
+
new(endpoint, project_id, uri.user)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def initialize(endpoint, project_id, public_key)
|
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
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Condux
|
|
4
|
+
# Ambient event enrichment: who the user is, which tags and contexts apply, and the breadcrumb trail
|
|
5
|
+
# leading up to an error. Set once (or as the app's state changes) and every subsequent event carries
|
|
6
|
+
# it — the first triage questions ("which customer, which plan, what did they do last") answered
|
|
7
|
+
# without threading anything through capture calls. The relay already scrubs all of these at ingest
|
|
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.
|
|
18
|
+
module Scope
|
|
19
|
+
# Newest trail wins: a long-lived process drops the oldest crumbs rather than growing without bound.
|
|
20
|
+
MAX_BREADCRUMBS = 30
|
|
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
|
+
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@user = nil
|
|
29
|
+
@tags = {}
|
|
30
|
+
@contexts = {}
|
|
31
|
+
@breadcrumbs = []
|
|
32
|
+
|
|
33
|
+
class << self
|
|
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.
|
|
55
|
+
def user=(user)
|
|
56
|
+
value = user&.transform_keys(&:to_s)
|
|
57
|
+
state = request_state
|
|
58
|
+
return state[:user] = value if state
|
|
59
|
+
|
|
60
|
+
@mutex.synchronize { @user = value }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Attach a tag to subsequent events; a nil value removes it.
|
|
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
|
+
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
if value.nil?
|
|
73
|
+
@tags.delete(key.to_s)
|
|
74
|
+
else
|
|
75
|
+
@tags[key.to_s] = value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Attach a named context object to subsequent events; nil removes it.
|
|
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
|
+
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
if value.nil?
|
|
91
|
+
@contexts.delete(name.to_s)
|
|
92
|
+
else
|
|
93
|
+
@contexts[name.to_s] = value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Record a breadcrumb; the trail (newest last, capped) rides every subsequent event.
|
|
99
|
+
def add_breadcrumb(message, category: nil, level: nil, type: nil, data: nil, timestamp: nil)
|
|
100
|
+
crumb = { "message" => message, "timestamp" => timestamp || Time.now.to_f }
|
|
101
|
+
crumb["category"] = category if category
|
|
102
|
+
crumb["level"] = level if level
|
|
103
|
+
crumb["type"] = type if type
|
|
104
|
+
crumb["data"] = data if data
|
|
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
|
+
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@breadcrumbs << crumb
|
|
115
|
+
@breadcrumbs.shift while @breadcrumbs.length > MAX_BREADCRUMBS
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reset all ambient state (tests, or a full sign-out). Clears the request scope when one is active.
|
|
120
|
+
def clear
|
|
121
|
+
state = request_state
|
|
122
|
+
if state
|
|
123
|
+
state.replace(user: nil, tags: {}, contexts: {}, breadcrumbs: [])
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@mutex.synchronize do
|
|
128
|
+
@user = nil
|
|
129
|
+
@tags = {}
|
|
130
|
+
@contexts = {}
|
|
131
|
+
@breadcrumbs = []
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The scope's contribution to an event, holding only the keys that are actually set so an
|
|
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.
|
|
140
|
+
def fields
|
|
141
|
+
state = request_state || {}
|
|
142
|
+
process = @mutex.synchronize do
|
|
143
|
+
{ user: @user&.dup, tags: @tags.dup, contexts: @contexts.dup, breadcrumbs: @breadcrumbs.dup }
|
|
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
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../condux"
|
|
4
|
+
|
|
5
|
+
module Condux
|
|
6
|
+
# `condux test-event` — prove the pipeline end to end.
|
|
7
|
+
#
|
|
8
|
+
# An error monitor's failure mode is silence, and silence looks exactly like health. This sends one
|
|
9
|
+
# info-level message through the real client and transport and reports the delivery outcome, so "did my
|
|
10
|
+
# DSN / network / relay work" is one command instead of waiting for a production error.
|
|
11
|
+
module TestEvent
|
|
12
|
+
USAGE = "Usage: condux test-event [--dsn <dsn>] [--message <text>]"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Runs the command; returns the process exit code (0 delivered, 1 failed, 2 usage).
|
|
17
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
18
|
+
command = argv.first
|
|
19
|
+
return usage(err, "unknown command '#{command}'") unless command == "test-event"
|
|
20
|
+
|
|
21
|
+
dsn = argument(argv, "--dsn") || ENV.fetch("CONDUX_DSN", nil)
|
|
22
|
+
return usage(err, "no DSN. Pass --dsn <dsn> or set CONDUX_DSN") if dsn.nil? || dsn.empty?
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
Condux.init(dsn: dsn, environment: "condux-test")
|
|
26
|
+
rescue ArgumentError, URI::InvalidURIError => e
|
|
27
|
+
return usage(err, e.message)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
report(Condux.capture_message(argument(argv, "--message") || "Condux test event"), out, err)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def report(result, out, err)
|
|
34
|
+
message_count = "#{result.attempts} attempt#{result.attempts == 1 ? "" : "s"}"
|
|
35
|
+
if result.ok
|
|
36
|
+
out.puts "Delivered (#{message_count}). Check your project's issues list; a test message " \
|
|
37
|
+
"appears as an info-level issue."
|
|
38
|
+
return 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
err.puts "Delivery FAILED after #{message_count}: #{result.error || "relay answered #{result.status}"}. " \
|
|
42
|
+
"Check the DSN (Project settings -> DSN keys) and that the ingest host is reachable."
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def argument(argv, name)
|
|
47
|
+
index = argv.index(name)
|
|
48
|
+
index && argv[index + 1]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def usage(err, reason)
|
|
52
|
+
err.puts "condux: #{reason}. #{USAGE}"
|
|
53
|
+
2
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/condux.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "condux/level"
|
|
4
4
|
require_relative "condux/send_result"
|
|
5
|
+
require_relative "condux/scope"
|
|
5
6
|
require_relative "condux/client"
|
|
6
7
|
|
|
7
8
|
# Condux SDK for Ruby — report errors to a Condux relay.
|
|
@@ -13,7 +14,8 @@ require_relative "condux/client"
|
|
|
13
14
|
# no real network or timers. Inspired by common SDK transports, implemented fresh.
|
|
14
15
|
module Condux
|
|
15
16
|
class << self
|
|
16
|
-
# Configure the SDK with a project DSN (and optional testing hooks).
|
|
17
|
+
# Configure the SDK with a project DSN (and optional testing hooks). Raises on a malformed DSN:
|
|
18
|
+
# setup runs once at developer time, so a typo is worth failing loudly for.
|
|
17
19
|
def init(dsn:, environment: nil, release: nil, max_retries: Client::DEFAULT_MAX_RETRIES,
|
|
18
20
|
transport: nil, sleep: nil, clock: nil)
|
|
19
21
|
@client = Client.new(dsn: dsn, environment: environment, release: release,
|
|
@@ -22,21 +24,71 @@ module Condux
|
|
|
22
24
|
|
|
23
25
|
# Report an exception as an error-level event, with its stack trace. Never raises on delivery failure.
|
|
24
26
|
# Pass handled: false for an uncaught exception (a framework integration does this).
|
|
25
|
-
def capture_exception(error, handled: true)
|
|
26
|
-
|
|
27
|
+
def capture_exception(error, handled: true, request: nil, tags: nil)
|
|
28
|
+
client = active_client
|
|
29
|
+
return not_initialized unless client
|
|
30
|
+
|
|
31
|
+
client.capture_exception(error, handled: handled, request: request, tags: tags)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
# Report a bare message event at the given level (default info).
|
|
30
35
|
def capture_message(message, level: Level::INFO)
|
|
31
|
-
|
|
36
|
+
client = active_client
|
|
37
|
+
return not_initialized unless client
|
|
38
|
+
|
|
39
|
+
client.capture_message(message, level)
|
|
40
|
+
end
|
|
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
|
+
|
|
48
|
+
# Attach the signed-in user (id/email/username) to subsequent events; nil clears.
|
|
49
|
+
def set_user(user)
|
|
50
|
+
Scope.user = user
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Attach a tag to subsequent events; a nil value removes it.
|
|
54
|
+
def set_tag(key, value)
|
|
55
|
+
Scope.set_tag(key, value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Attach a named context object to subsequent events; nil removes it.
|
|
59
|
+
def set_context(name, context)
|
|
60
|
+
Scope.set_context(name, context)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Record a breadcrumb; the trail (newest last, capped) rides every subsequent event.
|
|
64
|
+
def add_breadcrumb(message, category: nil, level: nil, type: nil, data: nil, timestamp: nil)
|
|
65
|
+
Scope.add_breadcrumb(message, category: category, level: level, type: type, data: data,
|
|
66
|
+
timestamp: timestamp)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reset all ambient enrichment (tests, or a full sign-out).
|
|
70
|
+
def clear_scope
|
|
71
|
+
Scope.clear
|
|
32
72
|
end
|
|
33
73
|
|
|
34
74
|
private
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
# Reporting never raises — an error monitor that raises turns a handled error into an unhandled one in
|
|
77
|
+
# exactly the code path where someone is already dealing with a failure. The Rack middleware reports
|
|
78
|
+
# from inside a rescue, so raising here would replace the application's own exception with this one.
|
|
79
|
+
# Warn once and drop the event instead.
|
|
80
|
+
def active_client
|
|
81
|
+
return @client if @client
|
|
82
|
+
|
|
83
|
+
unless @warned_uninitialized
|
|
84
|
+
@warned_uninitialized = true
|
|
85
|
+
warn "Condux: capture called before Condux.init(dsn:); events are being dropped."
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
38
89
|
|
|
39
|
-
|
|
90
|
+
def not_initialized
|
|
91
|
+
SendResult.new(ok: false, attempts: 0, error: "not_initialized")
|
|
40
92
|
end
|
|
41
93
|
end
|
|
42
94
|
end
|
metadata
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
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.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Condux
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
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).'
|
|
15
15
|
email:
|
|
16
|
-
executables:
|
|
16
|
+
executables:
|
|
17
|
+
- condux
|
|
17
18
|
extensions: []
|
|
18
19
|
extra_rdoc_files: []
|
|
19
20
|
files:
|
|
20
21
|
- LICENSE
|
|
21
22
|
- README.md
|
|
23
|
+
- exe/condux
|
|
22
24
|
- lib/condux.rb
|
|
23
25
|
- lib/condux/client.rb
|
|
24
26
|
- lib/condux/dsn.rb
|
|
@@ -26,7 +28,9 @@ files:
|
|
|
26
28
|
- lib/condux/event_transport.rb
|
|
27
29
|
- lib/condux/level.rb
|
|
28
30
|
- lib/condux/rack.rb
|
|
31
|
+
- lib/condux/scope.rb
|
|
29
32
|
- lib/condux/send_result.rb
|
|
33
|
+
- lib/condux/test_event.rb
|
|
30
34
|
homepage: https://condux.ai
|
|
31
35
|
licenses:
|
|
32
36
|
- Apache-2.0
|