condux 0.1.7 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '095069e5d14a6e32c481494fb6927c19441f56cc0a9cdc9dd6311acd9ef7e7c9'
4
- data.tar.gz: 1b3db61a8413e8fab802e39c13317b77c3418277b7f9a5b1c083b762cc64ba86
3
+ metadata.gz: 985adaf0c8807f4c03e4c83b2d4969bf37d505f7abba46d06cbb6de95bb83ff5
4
+ data.tar.gz: 466b2a9ddcdc2056a6bf38db174fabad21c3a920f6e3bea090c1a4b0bda493e2
5
5
  SHA512:
6
- metadata.gz: 5b12b5ffdee576bb9879191e5ba68748ce9a7b9cb81843422ce10b567d15776616d156cfcc1de0b3b8afb1caef5fec53501316d8d8a44e960ad78aa8f45eef9c
7
- data.tar.gz: 5ea29b8ae01b6f994d62b3b5af926d66a41878ae444f927fba82f05ab7d3cf03ab1f3c5db3e6a1ab8a158be2cfef7ec6132ac581f180d1f6859615260fbe5f37
6
+ metadata.gz: a638998e2b15a3f7c90c1c8c632ee8c400892ad848aeba80a4361eec7d178e2b5add4a00057c393042dded2b32d5244f2ae55958efad1c5c6aab7d3eb9256081
7
+ data.tar.gz: 6513420fa59559a2e5cee3237d6c91ed254342d45110348a18a15b4feab59924214c87c21823e84551803a73f85c752c0c000706cd1e0f7e33ecf4cedcff1ef4
data/README.md CHANGED
@@ -92,12 +92,27 @@ Condux.capture_exception(error, request: { "url" => "/api/sync" }, tags: { "job"
92
92
  require "condux/rack"
93
93
  use Condux::Rack::CaptureExceptions
94
94
 
95
- # Rails (config/application.rb)
96
- config.middleware.use "Condux::Rack::CaptureExceptions"
95
+ # Rails (config/application.rb), the require at the top of the file
96
+ require "condux/rack"
97
+ config.middleware.use Condux::Rack::CaptureExceptions
97
98
  ```
98
99
 
99
100
  Uncaught exceptions are reported as unhandled and re-raised, so the app's own error handling still runs.
100
101
 
102
+ **On Rails, caller-caused exceptions are re-raised but not reported.** Rails already classifies them:
103
+ `ActionDispatch::ExceptionWrapper.rescue_responses` maps an exception class to a status, and anything it
104
+ answers with a 4xx describes what the client sent rather than a defect in your app. A malformed JSON
105
+ body and a bad percent-encoded query both land there, and anyone can send those at will, so filing them
106
+ would let a stranger bury your real errors. Add your own with
107
+ `config.action_dispatch.rescue_responses`, which Condux reads too, so one setting governs your error
108
+ pages and your reporting together. Anything Rails does not classify still reports, since the registry
109
+ defaults to 500. Under bare Rack there is no such registry, so nothing is filtered.
110
+
111
+ **Pass the class, not its name as a string.** Rails builds each middleware with `klass.new(app)`, so a
112
+ string aborts boot with `undefined method 'new' for an instance of String`. Rails deprecated string
113
+ middleware in 5.0 and removed it in 5.1, so no supported version accepts it. The `require` matters too:
114
+ Bundler loads `condux`, which does not define `Condux::Rack`.
115
+
101
116
  **On Rails use `config.middleware.use`, and nothing else.** `use` appends, which puts the middleware at
102
117
  the bottom of the stack, inside `ActionDispatch::ShowExceptions`. That position is why it works:
103
118
  `ShowExceptions` catches a controller exception and turns it into a 500, so anything above it never sees
data/lib/condux/client.rb CHANGED
@@ -13,12 +13,14 @@ module Condux
13
13
  class Client
14
14
  DEFAULT_MAX_RETRIES = 3
15
15
 
16
- def initialize(dsn:, environment:, release:, max_retries:, transport:, sleep:, clock:)
16
+ def initialize(dsn:, environment:, release:, max_retries:, transport:, sleep:, clock:,
17
+ send_modules: true)
17
18
  parsed = Dsn.parse(dsn)
18
19
  @transport = EventTransport.new(parsed.store_url, parsed.public_key, max_retries, transport, sleep)
19
20
  @environment = environment
20
21
  @release = release
21
22
  @clock = clock || -> { Time.now }
23
+ @send_modules = send_modules
22
24
  end
23
25
 
24
26
  # +request+ (url/method/query_string) and +tags+ describe this one event. They are passed here
@@ -43,6 +45,9 @@ module Condux
43
45
  "platform" => "ruby",
44
46
  "level" => level,
45
47
  }.merge(Scope.fields)
48
+ # The runtime dependency inventory (ADR-0041). Collected HERE rather than at init because
49
+ # Gem.loaded_specs reports activated gems, and at boot almost nothing is activated yet.
50
+ event.merge!(Modules.fields(event["timestamp"])) if @send_modules
46
51
  event["request"] = request if request && !request.empty?
47
52
  # Merged over the ambient tags rather than replacing them, so a per-event tag cannot silently drop
48
53
  # the deployment-wide ones.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Condux
4
+ # The runtime dependency inventory that rides events (ADR-0041): which gem versions are actually
5
+ # loaded, as opposed to which ones a Gemfile declares. The relay indexes this so a security advisory
6
+ # can be answered with "and you are running 2.3.0 in production" rather than only "your lockfile
7
+ # says so".
8
+ #
9
+ # <b>This one is collected lazily, unlike the other SDKs, and that is not an inconsistency.</b>
10
+ # Gem.loaded_specs reports the gems that have been ACTIVATED, not the ones installed. Measured in a
11
+ # bare interpreter it holds a single entry, because nothing has been required yet. Collecting during
12
+ # Condux.init, which an application typically calls early in boot, would therefore report almost
13
+ # nothing. Collecting at capture time reports what was genuinely loaded by the moment the error
14
+ # happened, which is both correct and the truest reading of what this feature claims to measure. It
15
+ # costs nothing: loaded_specs is an in-memory hash, with no filesystem behind it.
16
+ module Modules
17
+ # The most entries carried on one event. The cap applies after sorting, so which entries survive is
18
+ # stable across events rather than varying with hash order: the server sees one consistent set
19
+ # instead of a shifting sample.
20
+ MAX_MODULES = 1000
21
+
22
+ # How long to wait before repeating the inventory on another event.
23
+ #
24
+ # This is what makes the feature affordable. The server deduplicates a release's inventory down to
25
+ # one row per package per day, so attaching the whole map to every event would spend bytes for
26
+ # nothing. Repeating on an interval rather than sending once keeps the robustness that every-event
27
+ # buys: the event carrying the inventory can be dropped by a rate limit or a quota rejection
28
+ # before anything parses it, so a single attempt per process would lose that day's inventory.
29
+ MODULES_INTERVAL_SECONDS = 15 * 60
30
+
31
+ module_function
32
+
33
+ # The loaded gems as a name to version hash, empty when they cannot be read.
34
+ #
35
+ # Never raises. This runs inside capture, which is already handling somebody's error, so a broken
36
+ # spec costs that entry and nothing else.
37
+ def collect
38
+ found = {}
39
+ Gem.loaded_specs.each do |name, spec|
40
+ version = spec.version.to_s
41
+ found[name.to_s] = version if name && !name.to_s.empty? && !version.empty?
42
+ rescue StandardError
43
+ # One unreadable spec is not a reason to report nothing about the rest.
44
+ next
45
+ end
46
+ found
47
+ rescue StandardError, NameError
48
+ # No RubyGems at all (a packaged binary, a trimmed runtime). Unknown is the honest answer.
49
+ {}
50
+ end
51
+
52
+ # The inventory's contribution to an event: the full map on the first event and then at most once
53
+ # per MODULES_INTERVAL_SECONDS, and an empty hash otherwise, so an event that carries nothing keeps
54
+ # its exact previous wire shape.
55
+ def fields(now)
56
+ return {} if @last_attached_at && (now - @last_attached_at) < MODULES_INTERVAL_SECONDS
57
+
58
+ loaded = collect
59
+ return {} if loaded.empty?
60
+
61
+ @last_attached_at = now
62
+ { "modules" => loaded.sort.first(MAX_MODULES).to_h }
63
+ end
64
+
65
+ # Reset the interval. Tests only; a process loads its gems once.
66
+ def reset
67
+ @last_attached_at = nil
68
+ end
69
+ end
70
+ end
data/lib/condux/rack.rb CHANGED
@@ -11,8 +11,15 @@ module Condux
11
11
  # require "condux/rack"
12
12
  # use Condux::Rack::CaptureExceptions
13
13
  #
14
- # # Rails (config/application.rb)
15
- # config.middleware.use "Condux::Rack::CaptureExceptions"
14
+ # # Rails (config/application.rb), the require at the top of the file
15
+ # require "condux/rack"
16
+ # config.middleware.use Condux::Rack::CaptureExceptions
17
+ #
18
+ # PASS THE CLASS, NOT ITS NAME AS A STRING. Rails builds each entry with `klass.new(app)`, so a
19
+ # String argument aborts boot with `undefined method 'new' for an instance of String`. Rails
20
+ # deprecated string middleware in 5.0 and removed the constantize in 5.1, so there is no supported
21
+ # version where the string form works. The require is needed too: Bundler loads `condux`, which
22
+ # does not define `Condux::Rack`, and without it boot fails on an uninitialized constant.
16
23
  #
17
24
  # ON RAILS, USE `config.middleware.use` AND NOTHING ELSE.
18
25
  #
@@ -36,12 +43,35 @@ module Condux
36
43
  # left to process state.
37
44
  Condux.request_scope do
38
45
  @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))
46
+ rescue StandardError => e # report what the app got wrong, then re-raise whatever it was
47
+ unless self.class.caller_caused?(e)
48
+ Condux.capture_exception(e, handled: false, request: self.class.request_fields(env))
49
+ end
41
50
  raise
42
51
  end
43
52
  end
44
53
 
54
+ # True when the framework classifies this exception as the CALLER's mistake, not the app's.
55
+ #
56
+ # Rails keeps that classification in ActionDispatch::ExceptionWrapper.rescue_responses, a public
57
+ # registry of exception class name to status. Asking it means there is no list of our own to keep
58
+ # extending, and `config.action_dispatch.rescue_responses` merges into the same registry, so an
59
+ # app's own classifications are honoured with no API from us.
60
+ #
61
+ # The registry defaults to :internal_server_error, so anything Rails does not recognise still
62
+ # reports: the rule fails toward reporting rather than toward silence. Measured against Rails
63
+ # 8.1.3.1, it answers 500 for an unknown class, an empty string and nil, and raises for none of
64
+ # them, which is why there is no defensive rescue here to go stale.
65
+ #
66
+ # Bare Rack has no equivalent registry, so with ActionDispatch absent nothing is filtered and the
67
+ # behaviour is exactly as before. See ADR-0044.
68
+ def self.caller_caused?(error)
69
+ return false unless defined?(ActionDispatch::ExceptionWrapper)
70
+
71
+ status = ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
72
+ status.is_a?(Integer) && status >= 400 && status < 500
73
+ end
74
+
45
75
  # The request, in the Sentry store shape the relay parses.
46
76
  #
47
77
  # Deliberately only the path, method and query string. Rack puts headers in HTTP_* keys, including
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/modules"
5
6
  require_relative "condux/scope"
6
7
  require_relative "condux/client"
7
8
 
@@ -17,9 +18,13 @@ module Condux
17
18
  # Configure the SDK with a project DSN (and optional testing hooks). Raises on a malformed DSN:
18
19
  # setup runs once at developer time, so a typo is worth failing loudly for.
19
20
  def init(dsn:, environment: nil, release: nil, max_retries: Client::DEFAULT_MAX_RETRIES,
20
- transport: nil, sleep: nil, clock: nil)
21
+ transport: nil, sleep: nil, clock: nil, send_modules: true)
21
22
  @client = Client.new(dsn: dsn, environment: environment, release: release,
22
- max_retries: max_retries, transport: transport, sleep: sleep, clock: clock)
23
+ max_retries: max_retries, transport: transport, sleep: sleep, clock: clock,
24
+ send_modules: send_modules)
25
+ # A re-init starts the interval afresh, so a new client does not inherit the previous one's
26
+ # "already sent recently" state and skip its first event.
27
+ Modules.reset
23
28
  end
24
29
 
25
30
  # Report an exception as an error-level event, with its stack trace. Never raises on delivery failure.
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.7
4
+ version: 0.2.0
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-16 00:00:00.000000000 Z
11
+ date: 2026-09-06 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).'
@@ -27,6 +27,7 @@ files:
27
27
  - lib/condux/event_payload.rb
28
28
  - lib/condux/event_transport.rb
29
29
  - lib/condux/level.rb
30
+ - lib/condux/modules.rb
30
31
  - lib/condux/rack.rb
31
32
  - lib/condux/scope.rb
32
33
  - lib/condux/send_result.rb