standard_circuit 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/LICENSE +22 -0
- data/README.md +50 -1
- data/lib/generators/standard_circuit/install/install_generator.rb +6 -0
- data/lib/generators/standard_circuit/install/templates/health_initializer.rb.tt +12 -0
- data/lib/generators/standard_circuit/install/templates/initializer.rb.tt +21 -0
- data/lib/standard_circuit/config.rb +51 -1
- data/lib/standard_circuit/engine.rb +20 -0
- data/lib/standard_circuit/notifiers/sentry.rb +73 -12
- data/lib/standard_circuit/subscribers.rb +1 -1
- data/lib/standard_circuit/version.rb +1 -1
- metadata +2 -2
- data/MIT-LICENSE +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac355e3881c42900c272ba14d433216f4d8e6019e003e4b5bd52f84545c11c79
|
|
4
|
+
data.tar.gz: ffdb732b389d5b752deef02d6cdc82fd148af38e3e861d046876cce329e50d36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2ffa97cfc68660ca8e32bd606df2251700f619f728969ce0e27ed485b6887eb6d8ecd6f1b641a0cea9d22c4842c3a5041a25b00bac97a84c37ea00ca78adf48
|
|
7
|
+
data.tar.gz: 8f245331cafaeb355b33e0ddd515bb23824713826d908544533959cd1f2dcb6c21d0160887370dadeef566a495d0474f66603095c0e1d93d1275f67c35f9f27d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-07-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `config.sentry_criticality_levels` — opt in to criticality-aware Sentry reporting for the built-in Sentry subscriber. Accepts `true` (the recommended map `{ critical: :error, standard: :warning, optional: :info }`), a partial Hash merged over that map, or `nil` / `false` for the previous flat behaviour. In criticality-aware mode a circuit-open report also gains `circuit` / `circuit_criticality` tags and a stable `["circuit-open", <circuit>]` fingerprint, so Sentry alert rules can page on `circuit_criticality:critical` and group one issue per breaker. Invalid criticalities and non-symbolizable levels raise `ArgumentError` at configure time rather than failing silently at alert time.
|
|
11
|
+
|
|
12
|
+
**This is opt-in, and deliberately not the new default.** Both the level and the fingerprint feed Sentry's alerting and issue grouping, so flipping the map on at gem-upgrade time would silently re-page and re-group live issues in apps that never asked for it. Apps that leave `sentry_criticality_levels` unset get the 0.2.x report byte-for-byte — same `:warning` level, same message, no tags, no fingerprint. Host apps that hand-rolled this by setting `sentry_enabled = false` and registering their own alerter can now delete that class and set `sentry_enabled = true` + `sentry_criticality_levels = true` instead.
|
|
13
|
+
- `isolate_namespace StandardCircuit` on the engine, bringing it in line with every other engine gem in the family. Verified non-breaking for the aggregate health route every consumer draws (`get "/health", to: "standard_circuit/health#show"`): that path is resolved by the *application's* route set through constant lookup, which isolation does not touch, and the new `spec/integration/health_route_boot_spec.rb` boots a real Rails app and requests the route end to end so a regression fails here rather than in a consumer after release. Safe specifically because this engine is library-only — no `config/routes.rb` for the isolated `default_scope` to scope, and no ActiveRecord models for the `standard_circuit_` `table_name_prefix` to apply to.
|
|
14
|
+
|
|
15
|
+
One consequence to know about: `StandardCircuit::HealthController` now picks up the engine's (empty) url helpers instead of the application's, so app path helpers used inside it — or inside a host subclass of it — need a `main_app.` prefix. The gem's controller only renders JSON, so nothing in-gem is affected.
|
|
16
|
+
- README section on `data_store`, previously undocumented. Spells out that the `Stoplight::DataStore::Memory` default is **per-process** — thresholds count per worker, `/health` reports the serving process's view, and `force_open` / `reset!` are process-local — plus the shared-store alternative for apps that want cross-process state.
|
|
17
|
+
- README section on Sentry reporting covering `sentry_enabled`, the new `sentry_criticality_levels` opt-in, and the "subscribe yourself" escape hatch.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- The install generator now warns that the aggregate `get "/health", to: "standard_circuit/health#show"` route must be drawn **before** `mount StandardHealth::Engine => "/health"`. `StandardHealth::Engine` registers sub-paths only (`/alive`, `/ready`, `/diagnostics/env`) and never serves the aggregate tier itself, so an app that mounts it and assumes `/health` is covered silently has no aggregate tier — with no boot error and no failing route spec to reveal it. The warning appears in the initializer template, in the `--with-health-endpoint` health initializer next to the route line, and in the hint the generator prints. Same note added to the README's health-endpoint section.
|
|
21
|
+
- `.github/workflows/ci.yml` follows the shared reusable workflow at `@v2` again, matching the rest of the gem family. The previous SHA pin carried a stale rationale: it claimed to restore pre-`rarebit-one/.github#14` job names for branch protection, but `main`'s protection now requires the *post*-#14 names (`ci / lint`, `ci / test-matrix (4.0.x)`) and the pinned revision's `reusable-gem-ci.yml` is byte-identical to `@v2` — so it was neither restoring old names nor changing any check name. It did honour `extra-lint-commands`, so the brakeman / bundler-audit gate was live throughout.
|
|
22
|
+
|
|
7
23
|
## [0.2.0] - 2026-04-28
|
|
8
24
|
|
|
9
25
|
### Added
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rarebit One
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -58,6 +58,48 @@ StandardCircuit.run(:stripe) do
|
|
|
58
58
|
end
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
## Circuit state storage (`data_store`)
|
|
62
|
+
|
|
63
|
+
Circuit state (failure counts, colors, locks) lives in a Stoplight data store. StandardCircuit defaults to `Stoplight::DataStore::Memory.new`, which is **per-process**: each Puma worker, Sidekiq/SolidQueue worker, and console gets its own independent view of every circuit.
|
|
64
|
+
|
|
65
|
+
That is the deliberate default for a Redis-free deployment, and it is usually the right one — a circuit exists to stop *this* process from hammering a dead upstream, and per-process thresholds mean one unlucky worker can't trip the breaker for everyone. But be explicit about what it implies:
|
|
66
|
+
|
|
67
|
+
- Thresholds are counted per process, so an app with 4 web workers tolerates roughly 4× the configured `threshold` in aggregate before every worker has tripped.
|
|
68
|
+
- `/health` reports the circuit colors of **the process that served the request**, so two consecutive probes can legitimately disagree while a circuit is tripping.
|
|
69
|
+
- `force_open` / `force_closed` and `reset!` affect only the calling process — they are test and console tools, not an operational kill switch.
|
|
70
|
+
|
|
71
|
+
Point `data_store` at a shared store if you want cross-process state instead:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
StandardCircuit.configure do |c|
|
|
75
|
+
# Default — per-process, no external dependency.
|
|
76
|
+
c.data_store = Stoplight::DataStore::Memory.new
|
|
77
|
+
|
|
78
|
+
# Shared across processes and hosts (requires the redis gem + a Redis server).
|
|
79
|
+
# c.data_store = Stoplight::DataStore::Redis.new(Redis.new(url: ENV["REDIS_URL"]))
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Sentry reporting
|
|
84
|
+
|
|
85
|
+
The built-in Sentry subscriber is on by default (`c.sentry_enabled = true`) and reports every circuit-open transition at a flat `:warning`, with the circuit name, colors, and error in `extra`.
|
|
86
|
+
|
|
87
|
+
Set `sentry_criticality_levels` to derive the level from the circuit's registered `criticality` instead. That also adds `circuit` / `circuit_criticality` tags and a stable `["circuit-open", <circuit>]` fingerprint, so Sentry alert rules can route on criticality (e.g. page on `circuit_criticality:critical`) and group per circuit:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
StandardCircuit.configure do |c|
|
|
91
|
+
# { critical: :error, standard: :warning, optional: :info }
|
|
92
|
+
c.sentry_criticality_levels = true
|
|
93
|
+
|
|
94
|
+
# Or override part of that map — unlisted criticalities keep the default.
|
|
95
|
+
# c.sentry_criticality_levels = { optional: :debug }
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This is **opt-in, not the default**. Both the level and the fingerprint feed Sentry's alerting and issue grouping, so turning it on for existing apps at gem-upgrade time would silently change what pages and re-group open issues. Leaving `sentry_criticality_levels` unset keeps the flat `:warning` shape byte-for-byte.
|
|
100
|
+
|
|
101
|
+
If you want something else entirely, set `c.sentry_enabled = false` and subscribe to `standard_circuit.circuit.opened` yourself — the payload carries `criticality`.
|
|
102
|
+
|
|
61
103
|
## Events
|
|
62
104
|
|
|
63
105
|
Every circuit lifecycle moment is emitted as a Rails event. On Rails 8.1+ the canonical bus is `Rails.event`; on older Rails versions the gem transparently falls back to `ActiveSupport::Notifications`. Detection happens per-emit, so subscribers do not need to care which backend is live.
|
|
@@ -147,7 +189,14 @@ end
|
|
|
147
189
|
|
|
148
190
|
The controller inherits from `ActionController::API` to sidestep app-level filters (authentication, bootstrap redirects, etc.) so probes can call it anonymously.
|
|
149
191
|
|
|
150
|
-
|
|
192
|
+
**If your app also mounts `StandardHealth::Engine` at `/health`, draw the aggregate route first:**
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
get "/health", to: "standard_circuit/health#show" # aggregate — FIRST
|
|
196
|
+
mount StandardHealth::Engine => "/health", as: :standard_health
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`StandardHealth::Engine` registers sub-paths only (`/alive`, `/ready`, `/diagnostics/env`) — it never serves the aggregate tier itself. An app that mounts the engine and assumes `/health` is covered silently has no aggregate tier at all, with no boot error and no failing route spec to reveal it. The ordering is load-bearing; draw the aggregate route explicitly, first.
|
|
151
200
|
|
|
152
201
|
## License
|
|
153
202
|
|
|
@@ -67,6 +67,12 @@ module StandardCircuit
|
|
|
67
67
|
say ""
|
|
68
68
|
say ' get "/health", to: "standard_circuit/health#show"'
|
|
69
69
|
say ""
|
|
70
|
+
say "If you also mount StandardHealth::Engine at \"/health\", draw the"
|
|
71
|
+
say "line above BEFORE the mount. That engine serves sub-paths only"
|
|
72
|
+
say "(/alive, /ready, /diagnostics/env), never the aggregate tier — so"
|
|
73
|
+
say "an app that mounts it and assumes \"/health\" is covered silently has"
|
|
74
|
+
say "no aggregate tier, with no boot error to warn you."
|
|
75
|
+
say ""
|
|
70
76
|
say "The controller returns 503 when the rolled-up circuit health is"
|
|
71
77
|
say ":critical and 200 otherwise — wire it up to your load balancer."
|
|
72
78
|
say "=" * 79
|
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
#
|
|
7
7
|
# get "/health", to: "standard_circuit/health#show"
|
|
8
8
|
#
|
|
9
|
+
# ORDERING IS LOAD-BEARING: if your app also mounts StandardHealth::Engine at
|
|
10
|
+
# "/health", draw this aggregate route BEFORE the mount —
|
|
11
|
+
#
|
|
12
|
+
# get "/health", to: "standard_circuit/health#show" # aggregate — FIRST
|
|
13
|
+
# mount StandardHealth::Engine => "/health", as: :standard_health
|
|
14
|
+
#
|
|
15
|
+
# StandardHealth::Engine registers sub-paths only ("/alive", "/ready",
|
|
16
|
+
# "/diagnostics/env") — it never serves the aggregate tier itself. So an app
|
|
17
|
+
# that mounts the engine and assumes "/health" is covered has no aggregate tier
|
|
18
|
+
# at all, and nothing says so: no boot error, and no failing route spec unless
|
|
19
|
+
# one already exists. Draw this route explicitly, first.
|
|
20
|
+
#
|
|
9
21
|
# The endpoint renders StandardCircuit.health_report as JSON and returns
|
|
10
22
|
# 503 when the rolled-up status is :critical (so orchestrators pull the
|
|
11
23
|
# instance out of rotation) and 200 otherwise.
|
|
@@ -17,6 +17,17 @@ StandardCircuit.configure do |config|
|
|
|
17
17
|
# Default: true
|
|
18
18
|
# config.sentry_enabled = true
|
|
19
19
|
|
|
20
|
+
# Report circuit-open events at a Sentry level chosen from the circuit's
|
|
21
|
+
# criticality, and add `circuit` / `circuit_criticality` tags plus a stable
|
|
22
|
+
# ["circuit-open", <circuit>] fingerprint so Sentry alert rules can route and
|
|
23
|
+
# group by circuit. Opt-in: leaving this unset keeps the flat :warning
|
|
24
|
+
# reporting (no tags, no fingerprint) that earlier versions used.
|
|
25
|
+
# true -> { critical: :error, standard: :warning, optional: :info }
|
|
26
|
+
# Hash -> the above, merged with your overrides
|
|
27
|
+
# Default: nil (flat :warning)
|
|
28
|
+
# config.sentry_criticality_levels = true
|
|
29
|
+
# config.sentry_criticality_levels = { optional: :debug }
|
|
30
|
+
|
|
20
31
|
# Prefix used for emitted metrics (e.g. "<prefix>.circuit_breaker",
|
|
21
32
|
# "<prefix>.request"). Default: "external"
|
|
22
33
|
# config.metric_prefix = "external"
|
|
@@ -80,4 +91,14 @@ StandardCircuit.configure do |config|
|
|
|
80
91
|
# contract as the built-in subscribers.
|
|
81
92
|
|
|
82
93
|
# config.add_notifier(->(name, payload) { MyTracer.record(name, payload) })
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Health endpoint (see --with-health-endpoint)
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# The aggregate route is `get "/health", to: "standard_circuit/health#show"`.
|
|
99
|
+
# If your app also mounts StandardHealth::Engine at "/health", draw the
|
|
100
|
+
# aggregate route BEFORE the mount. That engine registers sub-paths only
|
|
101
|
+
# ("/alive", "/ready", "/diagnostics/env") and never serves the aggregate tier
|
|
102
|
+
# itself, so an app that mounts it and assumes "/health" is covered silently
|
|
103
|
+
# has no aggregate tier — no boot error, no failing route spec.
|
|
83
104
|
end
|
|
@@ -34,10 +34,11 @@ module StandardCircuit
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
attr_accessor :sentry_enabled, :metric_prefix, :data_store, :logger
|
|
37
|
-
attr_reader :circuits, :prefixes, :extra_notifiers
|
|
37
|
+
attr_reader :circuits, :prefixes, :extra_notifiers, :sentry_criticality_levels
|
|
38
38
|
|
|
39
39
|
def initialize
|
|
40
40
|
@sentry_enabled = true
|
|
41
|
+
@sentry_criticality_levels = nil
|
|
41
42
|
@metric_prefix = "external"
|
|
42
43
|
@data_store = Stoplight::DataStore::Memory.new
|
|
43
44
|
@logger = nil
|
|
@@ -46,6 +47,22 @@ module StandardCircuit
|
|
|
46
47
|
@extra_notifiers = []
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# Opt in to criticality-aware Sentry reporting for the built-in Sentry
|
|
51
|
+
# subscriber. Accepts:
|
|
52
|
+
#
|
|
53
|
+
# nil / false — (default) flat :warning for every circuit, no tags and
|
|
54
|
+
# no fingerprint. The 0.2.x behaviour.
|
|
55
|
+
# true — Notifiers::Sentry::DEFAULT_LEVELS
|
|
56
|
+
# ({ critical: :error, standard: :warning, optional: :info })
|
|
57
|
+
# Hash — DEFAULT_LEVELS merged with the given criticality => level
|
|
58
|
+
# pairs, so a partial map is enough.
|
|
59
|
+
#
|
|
60
|
+
# Stored normalized: the reader returns either nil or a frozen, complete
|
|
61
|
+
# criticality => level Hash.
|
|
62
|
+
def sentry_criticality_levels=(value)
|
|
63
|
+
@sentry_criticality_levels = normalize_sentry_criticality_levels(value)
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
def reset_registry!
|
|
50
67
|
@circuits.clear
|
|
51
68
|
@prefixes.clear
|
|
@@ -90,6 +107,39 @@ module StandardCircuit
|
|
|
90
107
|
|
|
91
108
|
private
|
|
92
109
|
|
|
110
|
+
def normalize_sentry_criticality_levels(value)
|
|
111
|
+
case value
|
|
112
|
+
when nil, false then nil
|
|
113
|
+
when true then Notifiers::Sentry::DEFAULT_LEVELS
|
|
114
|
+
when Hash then merge_sentry_criticality_levels(value)
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError,
|
|
117
|
+
"sentry_criticality_levels must be nil, true, false, or a Hash of " \
|
|
118
|
+
"criticality => Sentry level; got #{value.class}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def merge_sentry_criticality_levels(map)
|
|
123
|
+
normalized = map.to_h do |criticality, level|
|
|
124
|
+
# Check symbolizability before coercing: a key like 42 would otherwise
|
|
125
|
+
# raise NoMethodError instead of the ArgumentError this setter promises.
|
|
126
|
+
criticality = criticality.to_sym if criticality.respond_to?(:to_sym)
|
|
127
|
+
unless CRITICALITIES.include?(criticality)
|
|
128
|
+
raise ArgumentError,
|
|
129
|
+
"invalid criticality #{criticality.inspect} in sentry_criticality_levels; " \
|
|
130
|
+
"must be one of #{CRITICALITIES.inspect}"
|
|
131
|
+
end
|
|
132
|
+
unless level.respond_to?(:to_sym)
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"invalid Sentry level #{level.inspect} for criticality #{criticality.inspect}; " \
|
|
135
|
+
"must be a Symbol or String"
|
|
136
|
+
end
|
|
137
|
+
[ criticality, level.to_sym ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Notifiers::Sentry::DEFAULT_LEVELS.merge(normalized).freeze
|
|
141
|
+
end
|
|
142
|
+
|
|
93
143
|
def spec_for_prefix(name)
|
|
94
144
|
key = name.to_s
|
|
95
145
|
_matched_prefix, spec = @prefixes.find { |prefix, _| key.start_with?("#{prefix}_") }
|
|
@@ -9,6 +9,26 @@ module StandardCircuit
|
|
|
9
9
|
# final state when the subscriber set is built. This is independent of
|
|
10
10
|
# ActiveRecord — apps that don't load AR still need observability.
|
|
11
11
|
class Engine < ::Rails::Engine
|
|
12
|
+
# Brings StandardCircuit in line with the other engine gems in the family.
|
|
13
|
+
# Safe here specifically because this engine is library-only:
|
|
14
|
+
#
|
|
15
|
+
# * No `config/routes.rb`, so the `default_scope` isolate_namespace applies
|
|
16
|
+
# to the engine's own route set has nothing to scope.
|
|
17
|
+
# * No ActiveRecord models, so the `standard_circuit_` table_name_prefix it
|
|
18
|
+
# defines on the StandardCircuit module applies to nothing.
|
|
19
|
+
# * The host-drawn route the convention prescribes —
|
|
20
|
+
# `get "/health", to: "standard_circuit/health#show"` — resolves through
|
|
21
|
+
# the *application's* route set by constant lookup, which isolation does
|
|
22
|
+
# not touch. Verified by booting a real Rails app both ways (see
|
|
23
|
+
# spec/integration/health_route_boot_spec.rb, which fails if this ever
|
|
24
|
+
# regresses).
|
|
25
|
+
#
|
|
26
|
+
# One real consequence: `StandardCircuit::HealthController` now picks up the
|
|
27
|
+
# engine's (empty) url_helpers instead of the application's, so app path
|
|
28
|
+
# helpers inside it — or inside a host subclass of it — need a `main_app.`
|
|
29
|
+
# prefix. The controller only renders JSON, so nothing in-gem is affected.
|
|
30
|
+
isolate_namespace StandardCircuit
|
|
31
|
+
|
|
12
32
|
initializer "standard_circuit.subscribers", after: :load_config_initializers do
|
|
13
33
|
StandardCircuit.subscribers.setup!
|
|
14
34
|
end
|
|
@@ -1,27 +1,88 @@
|
|
|
1
1
|
module StandardCircuit
|
|
2
2
|
module Notifiers
|
|
3
|
-
# Subscribes to standard_circuit.circuit.opened and forwards a
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Subscribes to standard_circuit.circuit.opened and forwards a message to
|
|
4
|
+
# Sentry. Other transitions are ignored — only RED matters for alerting.
|
|
5
|
+
#
|
|
6
|
+
# Two reporting shapes:
|
|
7
|
+
#
|
|
8
|
+
# * **Flat** (default, and the only 0.2.x behaviour): every circuit reports
|
|
9
|
+
# at `:warning` with the circuit's colors and error in `extra`. No tags,
|
|
10
|
+
# no fingerprint.
|
|
11
|
+
# * **Criticality-aware** (opt in via `config.sentry_criticality_levels`):
|
|
12
|
+
# the level is chosen from the circuit's registered criticality, and the
|
|
13
|
+
# report gains `circuit` / `circuit_criticality` tags plus a stable
|
|
14
|
+
# `["circuit-open", circuit]` fingerprint so Sentry alert rules can route
|
|
15
|
+
# and group by circuit.
|
|
16
|
+
#
|
|
17
|
+
# Flat stays the default deliberately. Both the level and the fingerprint
|
|
18
|
+
# feed Sentry's alerting and grouping, so a gem bump must not silently
|
|
19
|
+
# re-page or re-group a host app's existing issues — see CHANGELOG.
|
|
6
20
|
class Sentry
|
|
21
|
+
OPENED_EVENT = "standard_circuit.circuit.opened".freeze
|
|
22
|
+
|
|
23
|
+
# Level used in flat mode, and the fallback for an unrecognised
|
|
24
|
+
# criticality in criticality-aware mode.
|
|
25
|
+
DEFAULT_LEVEL = :warning
|
|
26
|
+
|
|
27
|
+
# The recommended criticality -> Sentry level mapping, used when
|
|
28
|
+
# `config.sentry_criticality_levels = true`. :critical is loud enough to
|
|
29
|
+
# page; :optional stays informational so a flapping nice-to-have
|
|
30
|
+
# upstream doesn't cry wolf.
|
|
31
|
+
DEFAULT_LEVELS = {
|
|
32
|
+
critical: :error,
|
|
33
|
+
standard: :warning,
|
|
34
|
+
optional: :info
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @param levels [Hash{Symbol=>Symbol}, nil] criticality -> level map.
|
|
38
|
+
# nil (the default) selects flat :warning reporting.
|
|
39
|
+
def initialize(levels: nil)
|
|
40
|
+
@levels = levels
|
|
41
|
+
end
|
|
42
|
+
|
|
7
43
|
def call(event_name, payload)
|
|
8
|
-
return unless event_name ==
|
|
44
|
+
return unless event_name == OPENED_EVENT
|
|
9
45
|
return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
|
|
10
46
|
|
|
47
|
+
@levels ? capture_criticality_aware(payload) : capture_flat(payload)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def capture_flat(payload)
|
|
11
53
|
message = "Circuit breaker opened: #{payload[:circuit]}"
|
|
12
54
|
::Sentry.capture_message(
|
|
13
55
|
message,
|
|
14
|
-
level:
|
|
15
|
-
extra:
|
|
16
|
-
circuit: payload[:circuit],
|
|
17
|
-
from_color: payload[:from_color],
|
|
18
|
-
to_color: payload[:to_color],
|
|
19
|
-
error_class: payload[:error_class],
|
|
20
|
-
error_message: payload[:error_message]
|
|
21
|
-
}.compact
|
|
56
|
+
level: DEFAULT_LEVEL,
|
|
57
|
+
extra: base_extra(payload)
|
|
22
58
|
)
|
|
23
59
|
message
|
|
24
60
|
end
|
|
61
|
+
|
|
62
|
+
def capture_criticality_aware(payload)
|
|
63
|
+
circuit = payload[:circuit].to_s
|
|
64
|
+
criticality = (payload[:criticality] || Config::DEFAULT_CRITICALITY).to_sym
|
|
65
|
+
message = "Circuit breaker opened: #{circuit} (#{criticality})"
|
|
66
|
+
|
|
67
|
+
::Sentry.capture_message(
|
|
68
|
+
message,
|
|
69
|
+
level: @levels.fetch(criticality, DEFAULT_LEVEL),
|
|
70
|
+
tags: { circuit: circuit, circuit_criticality: criticality.to_s },
|
|
71
|
+
fingerprint: [ "circuit-open", circuit ],
|
|
72
|
+
extra: base_extra(payload).merge(circuit: circuit, criticality: criticality)
|
|
73
|
+
)
|
|
74
|
+
message
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def base_extra(payload)
|
|
78
|
+
{
|
|
79
|
+
circuit: payload[:circuit],
|
|
80
|
+
from_color: payload[:from_color],
|
|
81
|
+
to_color: payload[:to_color],
|
|
82
|
+
error_class: payload[:error_class],
|
|
83
|
+
error_message: payload[:error_message]
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
25
86
|
end
|
|
26
87
|
end
|
|
27
88
|
end
|
|
@@ -72,7 +72,7 @@ module StandardCircuit
|
|
|
72
72
|
def internal_subscribers
|
|
73
73
|
config = StandardCircuit.config
|
|
74
74
|
list = [ Notifiers::Logger.new(config.logger) ]
|
|
75
|
-
list << Notifiers::Sentry.new if config.sentry_enabled
|
|
75
|
+
list << Notifiers::Sentry.new(levels: config.sentry_criticality_levels) if config.sentry_enabled
|
|
76
76
|
list << Notifiers::Metrics.new(metric_prefix: config.metric_prefix)
|
|
77
77
|
list
|
|
78
78
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_circuit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -117,7 +117,7 @@ extensions: []
|
|
|
117
117
|
extra_rdoc_files: []
|
|
118
118
|
files:
|
|
119
119
|
- CHANGELOG.md
|
|
120
|
-
-
|
|
120
|
+
- LICENSE
|
|
121
121
|
- README.md
|
|
122
122
|
- Rakefile
|
|
123
123
|
- lib/active_storage/service/standard_circuit_s3_service.rb
|
data/MIT-LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Jaryl Sim
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|