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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3db626d815c96cf4115cb51ea7fc2fa076b14b06f90c8b3d4732694810a00aef
4
- data.tar.gz: e819143aff9c172bafea74fc17b2a7a2012657cab8f3fca06f3981d20d45b879
3
+ metadata.gz: ba08155338fd7b32dbe552dadae5337c73090f5f6a57fb0dcee5de53e9f47708
4
+ data.tar.gz: 04ef85c7632b4f681765972446f279f072bd9b4415be3c4261675f8819b2e84b
5
5
  SHA512:
6
- metadata.gz: b7697052843054cbbdf929fb9cfa6671b0c0489e2dec35c162c62f57fed5249532c4713d36811db506ec9463ca106d90354174dd97818690f25a6d22ea2f0f2c
7
- data.tar.gz: ad5d223a15da543bbcf903b4bbab2b899c3712f9f8859b5f94f84c5fa226f92512d2a15d71d20b7b57a6f1368fb786efcd3365256810914d1d20f13504e9b817
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 test/test_condux.rb
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "condux/test_event"
5
+
6
+ exit Condux::TestEvent.run(ARGV)
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
- def capture_exception(error, handled: true)
24
- 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)
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
- if uri.user.nil? || uri.user.empty? || uri.host.nil?
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, uri.path.sub(%r{\A/}, ""), uri.user)
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
- @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
@@ -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
- require_client.capture_exception(error, handled: handled)
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
- require_client.capture_message(message, level)
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
- def require_client
37
- raise "Condux not initialized call Condux.init(dsn:) first" unless @client
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
- @client
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
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Condux
8
8
  autorequire:
9
- bindir: bin
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).'
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