chronos-ruby 0.2.0.pre.1 → 0.4.0.pre.1
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 +48 -0
- data/README.md +60 -19
- data/contracts/rack-context-v1.schema.json +43 -0
- data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
- data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
- data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
- data/docs/architecture.md +15 -6
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +22 -0
- data/docs/data-collected.md +8 -2
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/modules/async-queue.md +6 -2
- data/docs/modules/notice-pipeline.md +2 -1
- data/docs/modules/rack-context.md +59 -0
- data/docs/modules/remote-configuration.md +54 -0
- data/docs/modules/retry-backlog.md +60 -0
- data/docs/modules/transport.md +5 -3
- data/docs/performance.md +39 -2
- data/docs/privacy-lgpd.md +10 -2
- data/docs/troubleshooting.md +10 -2
- data/lib/chronos/adapters/net_http_transport.rb +21 -3
- data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
- data/lib/chronos/agent.rb +68 -11
- data/lib/chronos/application/capture_exception.rb +20 -14
- data/lib/chronos/application/circuit_breaker.rb +70 -0
- data/lib/chronos/application/delivery_pipeline.rb +248 -0
- data/lib/chronos/application/remote_configuration.rb +173 -0
- data/lib/chronos/application/retry_policy.rb +57 -0
- data/lib/chronos/configuration.rb +128 -20
- data/lib/chronos/core/breadcrumb.rb +126 -0
- data/lib/chronos/core/payload_serializer.rb +4 -2
- data/lib/chronos/integrations/rack/middleware.rb +152 -0
- data/lib/chronos/integrations/rack.rb +12 -0
- data/lib/chronos/integrations.rb +10 -0
- data/lib/chronos/internal/bounded_queue.rb +1 -1
- data/lib/chronos/internal/memory_backlog.rb +65 -0
- data/lib/chronos/internal/worker_pool.rb +5 -6
- data/lib/chronos/ports/context_store.rb +22 -0
- data/lib/chronos/ports/transport.rb +4 -3
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +26 -1
- metadata +18 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Remote configuration module
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Operators may need to reduce event volume or stop collection during an incident without redeploying a legacy application. Accepting unrestricted server instructions would create remote-code, SSRF, credential, privacy, and memory risks.
|
|
6
|
+
|
|
7
|
+
## Boundary
|
|
8
|
+
|
|
9
|
+
`NetHttpTransport` only parses a bounded JSON object from the `X-Chronos-Remote-Configuration` success-response header. `Chronos::Application::RemoteConfiguration` owns the allowlist and local upper bounds. `DeliveryPipeline` applies accepted policy to later capture and delivery work.
|
|
10
|
+
|
|
11
|
+
## Allowed fields
|
|
12
|
+
|
|
13
|
+
| Field | Accepted value | Local restriction |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `sampling_rate` | Number from `0.0` to `1.0` | Cannot exceed local `sampling_rate` |
|
|
16
|
+
| `enabled_event_types` | Array of strings | Intersected with local and implemented event types |
|
|
17
|
+
| `max_payload_size` | Integer | Cannot exceed local maximum or fall below 256 bytes |
|
|
18
|
+
| `ignored_fingerprints` | Up to 100 strings | Exact matching only; 256 bytes per value |
|
|
19
|
+
| `send_interval` | Non-negative seconds | Cannot exceed `max_remote_send_interval` |
|
|
20
|
+
| `kill_switch` | Boolean | Stops later capture while active |
|
|
21
|
+
|
|
22
|
+
Unknown keys are ignored. If a recognized value is invalid, the document is rejected atomically and the previous policy remains active.
|
|
23
|
+
|
|
24
|
+
## Explicitly forbidden
|
|
25
|
+
|
|
26
|
+
Remote configuration cannot change host, proxy, project ID, project key, TLS verification, logger, queue or backlog capacity, retry limits, local upper bounds, Ruby code, object serialization, regular expressions, or arbitrary headers. No `eval`, `send`, `Marshal`, YAML, or application callback is used.
|
|
27
|
+
|
|
28
|
+
## Example server response
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
HTTP/1.1 202 Accepted
|
|
32
|
+
X-Chronos-Remote-Configuration: {"sampling_rate":0.25,"send_interval":1.0,"kill_switch":false}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Disable this channel locally when it is not required:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Chronos.configure do |config|
|
|
39
|
+
# connection settings omitted
|
|
40
|
+
config.remote_configuration = false
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Risks and limits
|
|
45
|
+
|
|
46
|
+
- The policy is process-local and is not persisted across restarts.
|
|
47
|
+
- A stale policy remains until another accepted response changes it.
|
|
48
|
+
- The header size is bounded before JSON parsing, but upstream proxies may impose a lower header limit.
|
|
49
|
+
- Sampling uses process-local randomness and is not a globally exact rate.
|
|
50
|
+
- Version 0.3 does not poll a separate configuration endpoint.
|
|
51
|
+
|
|
52
|
+
## Tests
|
|
53
|
+
|
|
54
|
+
Tests cover the field allowlist, local caps, unsupported event types, invalid types, regex rejection, forbidden endpoint and credential keys, response-header byte limits, kill switch, sampling, and atomic preservation of previous state.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Retry and backlog module
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Transient endpoint failures must not discard every event immediately, block application threads indefinitely, or grow memory without a limit.
|
|
6
|
+
|
|
7
|
+
## Boundary
|
|
8
|
+
|
|
9
|
+
Retry belongs to `Chronos::Application::DeliveryPipeline`, not `NetHttpTransport`, because HTTP classifies an outcome while application policy decides whether another attempt is acceptable. `Chronos::Internal::MemoryBacklog` is separate from the asynchronous queue because it stores failed deliveries, not newly accepted work.
|
|
10
|
+
|
|
11
|
+
## Data flow
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
stateDiagram-v2
|
|
15
|
+
[*] --> accepted
|
|
16
|
+
accepted --> serialized
|
|
17
|
+
serialized --> queued: asynchronous capture
|
|
18
|
+
queued --> sent: successful delivery
|
|
19
|
+
serialized --> sent: synchronous delivery
|
|
20
|
+
queued --> retried: network, 408, 429, or 5xx
|
|
21
|
+
retried --> sent: later attempt succeeds
|
|
22
|
+
retried --> backlog: attempts exhausted or circuit open
|
|
23
|
+
backlog --> sent: later half-open recovery
|
|
24
|
+
backlog --> dropped: backlog full
|
|
25
|
+
queued --> rejected: permanent 4xx
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`RetryPolicy` calculates exponential backoff with positive bounded jitter. `Retry-After` is respected only within `retry_max_interval`. `CircuitBreaker` opens after the configured consecutive failure count, rejects concurrent probes, and permits one half-open probe after `circuit_reset_timeout`.
|
|
29
|
+
|
|
30
|
+
The memory backlog accepts only `SerializedEvent`. Sanitization and safe serialization therefore happen before an event can enter retry storage. Its capacity is `backlog_size`; zero disables retention. The oldest stored event is attempted as new delivery activity arrives. No background timer or additional thread exists for backlog draining.
|
|
31
|
+
|
|
32
|
+
## Configuration and extension
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Chronos.configure do |config|
|
|
36
|
+
# connection settings omitted
|
|
37
|
+
config.max_retries = 3
|
|
38
|
+
config.retry_base_interval = 0.5
|
|
39
|
+
config.retry_max_interval = 30.0
|
|
40
|
+
config.retry_jitter = 0.25
|
|
41
|
+
config.backlog_size = 100
|
|
42
|
+
config.circuit_failure_threshold = 5
|
|
43
|
+
config.circuit_reset_timeout = 30.0
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tests may inject a clock, sleeper, random source, backlog, or retry policy into `DeliveryPipeline`. These are internal composition points rather than stable public configuration APIs.
|
|
48
|
+
|
|
49
|
+
## Risks and limits
|
|
50
|
+
|
|
51
|
+
- Events in memory are lost on process exit.
|
|
52
|
+
- A quiet process does not drain backlog until another event triggers delivery.
|
|
53
|
+
- Backlog order is best effort when multiple workers are active.
|
|
54
|
+
- Retry sleeps occupy a fixed worker, never the application caller for asynchronous capture.
|
|
55
|
+
- Synchronous capture waits for configured retries and should be used only when that latency is acceptable.
|
|
56
|
+
- Disk persistence is deliberately excluded from version 0.3.
|
|
57
|
+
|
|
58
|
+
## Tests
|
|
59
|
+
|
|
60
|
+
Unit tests cover bounded exponential delay, retry limits, `Retry-After`, permanent `4xx`, circuit opening and half-open recovery, fixed backlog capacity, raw-object rejection, prolonged outage, state counters, and recovery draining. The privacy contract proves that fixture secrets do not enter backlog storage.
|
data/docs/modules/transport.md
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
`Chronos::Ports::Transport` defines delivery behavior. `Chronos::Adapters::NetHttpTransport` implements it with the Ruby standard library.
|
|
4
4
|
|
|
5
|
-
Each event uses bounded open and read timeouts. TLS verification is enabled by default. Authentication and idempotency values are headers. Redirects are not followed. Statuses are classified as success, client error, rate limited, server error, network error, or closed.
|
|
5
|
+
Each event uses bounded open and read timeouts. TLS verification is enabled by default. Authentication and idempotency values are headers. Redirects are not followed. Statuses are classified as success, request timeout, client error, rate limited, server error, network error, or closed. HTTP `408`, `429`, `5xx`, and network failures are retryable; other `4xx` responses are permanent.
|
|
6
6
|
|
|
7
|
-
The adapter creates a connection per event in version 0.
|
|
7
|
+
The adapter creates a connection per event in version 0.3. This is conservative for legacy applications and avoids shared socket lifecycle state. Retry remains in `DeliveryPipeline`, behind the transport port.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
A successful response may contain `X-Chronos-Remote-Configuration`. The adapter rejects the header when it exceeds `remote_config_max_bytes` or is not a JSON object. It never interprets policy or accepts remote endpoint and credential changes; that allowlist belongs to `RemoteConfiguration`.
|
|
10
|
+
|
|
11
|
+
Risks include proxy credential exposure, TLS incompatibility on old operating systems, endpoint latency, and stale remote policy until another response arrives. Tests use a local fake HTTP server to verify headers, `2xx`, `408`, `429`, `500`, timeout, invalid TLS, and bounded remote policy parsing.
|
data/docs/performance.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Performance
|
|
2
2
|
|
|
3
|
-
Performance is a functional requirement, but version 0.
|
|
3
|
+
Performance is a functional requirement, but version 0.4 makes no unverified speed claim.
|
|
4
4
|
|
|
5
5
|
Current controls:
|
|
6
6
|
|
|
@@ -12,9 +12,14 @@ Current controls:
|
|
|
12
12
|
- worker count is fixed and threads start lazily;
|
|
13
13
|
- producers never wait for queue capacity;
|
|
14
14
|
- HTTP open and read timeouts are explicit;
|
|
15
|
+
- retry count, delay, jitter, and `Retry-After` are capped;
|
|
16
|
+
- the circuit breaker suppresses requests during sustained failure;
|
|
17
|
+
- retry backlog capacity is fixed and may be disabled;
|
|
15
18
|
- shutdown and flush have caller-controlled timeouts.
|
|
19
|
+
- request context and breadcrumbs have fixed structural and byte limits;
|
|
20
|
+
- Rack middleware never consumes request or response bodies.
|
|
16
21
|
|
|
17
|
-
Run the scripts under `benchmarks/` and record Ruby version, operating system, CPU, warmup, iteration count, median, and dispersion before publishing results. `benchmarks/filtering.rb` measures
|
|
22
|
+
Run the scripts under `benchmarks/` and record Ruby version, operating system, CPU, warmup, iteration count, median, and dispersion before publishing results. `benchmarks/filtering.rb` measures privacy filtering, `benchmarks/retry_backlog.rb` measures fixed-memory outage behavior, and `benchmarks/request_overhead.rb` compares a successful direct Rack-protocol call with the middleware path.
|
|
18
23
|
|
|
19
24
|
## Version 0.2 development measurement
|
|
20
25
|
|
|
@@ -26,3 +31,35 @@ A local diagnostic run on 2026-07-19 used macOS arm64 (`T8103`), legacy x86_64 R
|
|
|
26
31
|
| Ruby 2.6.3 | 1,904 µs/event | 4,705 µs/event | 1,037 µs/pass |
|
|
27
32
|
|
|
28
33
|
The capture benchmark uses an in-memory transport and includes queue draining. The serialization fixture contains 20 backtrace frames; the filtering fixture contains nested fields and 20 repeated items. These are single aggregate development runs, not published performance claims: they do not provide median or dispersion and must be repeated on controlled hardware before comparison with another agent.
|
|
34
|
+
|
|
35
|
+
## Version 0.3 resilience benchmark
|
|
36
|
+
|
|
37
|
+
Run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
ITERATIONS=10000 bundle _1.17.3_ exec ruby benchmarks/retry_backlog.rb
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The benchmark opens the circuit after one synthetic network failure, retains at most 100 serialized events, and measures bounded rejection into a full backlog. It performs no network I/O and is intended to detect accidental unbounded growth or excessive control-path overhead, not to claim production throughput.
|
|
44
|
+
|
|
45
|
+
A local diagnostic run on 2026-07-20 used Ruby 2.2.10 on macOS arm64 with 1,000 capture, serialization, and filtering iterations plus 10,000 queue and outage iterations:
|
|
46
|
+
|
|
47
|
+
| Measurement | Result |
|
|
48
|
+
|---|---:|
|
|
49
|
+
| Asynchronous local capture | 1,824 µs/event |
|
|
50
|
+
| Full serialization | 3,961 µs/event |
|
|
51
|
+
| Privacy filtering fixture | 777 µs/pass |
|
|
52
|
+
| Bounded queue | 1,648,533 operations/second |
|
|
53
|
+
| Open-circuit backlog handling | 88,922 operations/second |
|
|
54
|
+
|
|
55
|
+
The outage run retained exactly 100 events and rejected 9,900 additional events without growing the backlog. These are single development runs without median or dispersion and are not comparative performance claims.
|
|
56
|
+
|
|
57
|
+
## Version 0.4 Rack middleware benchmark
|
|
58
|
+
|
|
59
|
+
Run:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ITERATIONS=100000 bundle _1.17.3_ exec ruby benchmarks/request_overhead.rb
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A local diagnostic run on 2026-07-20 used Ruby 2.2.10 on macOS arm64 and 10,000 successful Rack-protocol calls. The direct application calls took 0.013272 seconds and middleware calls took 0.539758 seconds, for an estimated 52.649 microseconds of middleware work per request. The fixture uses no network, error capture, or response-body enumeration. This is a single development measurement, not a production latency claim; controlled warmup, median, and dispersion remain required before publishing a performance claim.
|
data/docs/privacy-lgpd.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Privacy and LGPD
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 0.4 sanitizes every exception event before JSON serialization, queueing, retry backlog, or transport. This reduces accidental exposure, but the host application remains responsible for lawful purpose, minimization, access control, retention, and responses to data-subject requests.
|
|
4
4
|
|
|
5
5
|
## Default policy
|
|
6
6
|
|
|
@@ -13,7 +13,15 @@ Version 0.2 sanitizes every exception event before JSON serialization, queueing,
|
|
|
13
13
|
| Payment-card candidates that pass the Luhn check | Replaced with `[FILTERED_CARD]` |
|
|
14
14
|
| IPv4 addresses | Last octet replaced with `0` |
|
|
15
15
|
| Unknown Ruby objects | Represented by class name without calling application serialization |
|
|
16
|
-
| Request/response bodies, cookies,
|
|
16
|
+
| Request/response bodies, raw query strings, cookies, authorization headers, SQL binds, environment variables | Never collected automatically in version 0.4 |
|
|
17
|
+
|
|
18
|
+
The retry backlog exists only in process memory, accepts only `SerializedEvent`, has a fixed capacity, and disappears on process exit. Version 0.4 does not persist telemetry to disk.
|
|
19
|
+
|
|
20
|
+
## Rack context and breadcrumbs
|
|
21
|
+
|
|
22
|
+
The Rack middleware copies only bounded operational fields and parameter hashes that another component already parsed. It does not read `rack.input` or copy `QUERY_STRING`. User context is opt-in through `chronos.user`, and user agent collection is disabled unless the middleware option enables it.
|
|
23
|
+
|
|
24
|
+
Breadcrumbs contain category, bounded message, bounded metadata, and timestamp. No raw request, response, SQL, cache, job, log, or external HTTP payload is added automatically. Rack context and breadcrumbs pass through the same sanitizer as manual context before queueing.
|
|
17
25
|
|
|
18
26
|
Blocklist matching accepts `String`, `Symbol`, and `Regexp`. String and Symbol matching is case-insensitive after punctuation normalization and also protects namespaced keys such as `user_password`.
|
|
19
27
|
|
data/docs/troubleshooting.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Configuration raises an error
|
|
4
4
|
|
|
5
|
-
Verify `project_id`, `project_key`, and `host`. HTTPS is required while `ssl_verify` is true. Queue size, worker count, payload size, and timeouts must be positive.
|
|
5
|
+
Verify `project_id`, `project_key`, and `host`. HTTPS is required while `ssl_verify` is true. Queue size, worker count, payload size, breadcrumb capacity, and timeouts must be positive. `breadcrumb_max_bytes` must be at least 128.
|
|
6
6
|
|
|
7
7
|
## `Chronos.notify` returns false
|
|
8
8
|
|
|
@@ -10,7 +10,15 @@ The agent may be unconfigured, disabled, ignored in the current environment, una
|
|
|
10
10
|
|
|
11
11
|
## `Chronos.notify_sync` returns false
|
|
12
12
|
|
|
13
|
-
Check DNS, TLS certificates, credentials, HTTP status, proxy configuration, and timeout values.
|
|
13
|
+
Check DNS, TLS certificates, credentials, HTTP status, proxy configuration, and timeout values. The resilience layer retries only network errors, HTTP `408`, `429`, and `5xx` responses. Inspect `agent.diagnostics` when constructing an agent directly to see retry state, backlog usage, and the circuit state.
|
|
14
|
+
|
|
15
|
+
## Rack exception is not captured
|
|
16
|
+
|
|
17
|
+
Confirm that `Chronos.configure` runs before the middleware handles requests and that the middleware wraps the application component that raises. Version 0.4 captures exceptions raised by the initial downstream Rack call; an exception raised later while a server enumerates a streaming response body is outside this release. The original exception is always re-raised, so the server log should still show it.
|
|
18
|
+
|
|
19
|
+
## Context appears missing
|
|
20
|
+
|
|
21
|
+
The legacy context store is thread-local. A new application-created thread does not inherit context. Establish a new `Chronos.with_context` scope inside that thread, and issue manual notification before the scope exits. For Rack capture, supply user and explicit parameters through the documented environment keys.
|
|
14
22
|
|
|
15
23
|
## Events disappear during shutdown
|
|
16
24
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "openssl"
|
|
3
|
+
require "json"
|
|
3
4
|
require "stringio"
|
|
4
5
|
require "uri"
|
|
5
6
|
require "zlib"
|
|
@@ -10,16 +11,17 @@ module Chronos
|
|
|
10
11
|
#
|
|
11
12
|
# @responsibility Send serialized events over bounded HTTPS requests.
|
|
12
13
|
# @motivation Use the Ruby standard library to preserve legacy compatibility.
|
|
13
|
-
# @limits It classifies failures but
|
|
14
|
+
# @limits It classifies failures but leaves retry and backlog policy to the application layer.
|
|
14
15
|
# @collaborators Configuration::Snapshot, SerializedEvent, and TransportResult.
|
|
15
16
|
# @thread_safety Creates a new Net::HTTP connection per call and synchronizes health state.
|
|
16
17
|
# @compatibility Ruby 2.2.10 through Ruby 2.6; no Rails dependency.
|
|
17
18
|
# @example
|
|
18
19
|
# result = transport.send_event(serialized_event)
|
|
19
20
|
# @errors Network, TLS, and HTTP errors are returned, never raised to callers.
|
|
20
|
-
# @performance One bounded network request per
|
|
21
|
+
# @performance One bounded network request per delivery attempt in version 0.3.
|
|
21
22
|
class NetHttpTransport
|
|
22
23
|
EVENT_PATH = "/api/v1/events".freeze
|
|
24
|
+
REMOTE_CONFIGURATION_HEADER = "X-Chronos-Remote-Configuration".freeze
|
|
23
25
|
|
|
24
26
|
def initialize(config, logger = nil)
|
|
25
27
|
@config = config
|
|
@@ -100,7 +102,11 @@ module Chronos
|
|
|
100
102
|
def classify(response)
|
|
101
103
|
code = response.code.to_i
|
|
102
104
|
options = {:status_code => code}
|
|
103
|
-
|
|
105
|
+
if code >= 200 && code < 300
|
|
106
|
+
options[:remote_configuration] = parse_remote_configuration(response)
|
|
107
|
+
return Ports::TransportResult.new(:success, options)
|
|
108
|
+
end
|
|
109
|
+
return Ports::TransportResult.new(:request_timeout, options) if code == 408
|
|
104
110
|
if code == 429
|
|
105
111
|
options[:retry_after] = response["Retry-After"]
|
|
106
112
|
return Ports::TransportResult.new(:rate_limited, options)
|
|
@@ -110,6 +116,18 @@ module Chronos
|
|
|
110
116
|
Ports::TransportResult.new(:client_error, options)
|
|
111
117
|
end
|
|
112
118
|
|
|
119
|
+
def parse_remote_configuration(response)
|
|
120
|
+
return nil unless @config.remote_configuration
|
|
121
|
+
|
|
122
|
+
value = response[REMOTE_CONFIGURATION_HEADER]
|
|
123
|
+
return nil if value.nil? || value.bytesize > @config.remote_config_max_bytes
|
|
124
|
+
|
|
125
|
+
parsed = JSON.parse(value)
|
|
126
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
127
|
+
rescue JSON::ParserError, EncodingError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
113
131
|
def request_body(body)
|
|
114
132
|
return body unless @config.gzip
|
|
115
133
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Adapters
|
|
3
|
+
# Stores execution context in the current Ruby thread.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Isolate request context and restore nested scopes reliably.
|
|
6
|
+
# @motivation Legacy Ruby does not provide a portable fiber-local storage API.
|
|
7
|
+
# @limits Context does not propagate to new threads or across processes.
|
|
8
|
+
# @collaborators ContextStore port and Rack middleware.
|
|
9
|
+
# @thread_safety Each thread owns an independent value; one instance may be shared.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# store.with_context(:request_id => "r1") { store.get }
|
|
13
|
+
# @errors The previous value is restored in ensure even when the block raises.
|
|
14
|
+
# @performance Reads and writes are constant-time thread-local operations.
|
|
15
|
+
class ThreadLocalContextStore
|
|
16
|
+
def initialize
|
|
17
|
+
@key = "chronos_context_#{object_id}".freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get
|
|
21
|
+
Thread.current[@key] || {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def set(context)
|
|
25
|
+
raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
Thread.current[@key] = context
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear
|
|
31
|
+
Thread.current[@key] = nil
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with_context(context)
|
|
36
|
+
previous = Thread.current[@key]
|
|
37
|
+
set(merge_context(previous || {}, context))
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
previous ? Thread.current[@key] = previous : clear
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def merge_context(current, additional)
|
|
46
|
+
raise ArgumentError, "context must be a Hash" unless additional.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
current.merge(additional)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/chronos/agent.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
module Chronos
|
|
2
2
|
# Runtime composition root for the framework-independent Chronos agent.
|
|
3
3
|
#
|
|
4
|
-
# @responsibility Own
|
|
4
|
+
# @responsibility Own delivery, capture, execution context, and breadcrumb collaborators.
|
|
5
5
|
# @motivation Keep construction details outside the public module facade.
|
|
6
|
-
# @limits It does not
|
|
7
|
-
# @collaborators
|
|
6
|
+
# @limits It does not install Rack, Rails, or job integrations automatically.
|
|
7
|
+
# @collaborators CaptureException, DeliveryPipeline, ContextStore, and BreadcrumbBuffer.
|
|
8
8
|
# @thread_safety Runtime collaborators synchronize mutable state.
|
|
9
9
|
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
10
10
|
# @example
|
|
@@ -19,39 +19,96 @@ module Chronos
|
|
|
19
19
|
def initialize(config, options = {})
|
|
20
20
|
@config = config
|
|
21
21
|
@logger = options[:logger] || Internal::SafeLogger.new(config.logger)
|
|
22
|
+
@context_store = options[:context_store] || build_context_store(config.context_store)
|
|
23
|
+
unless Ports::ContextStore.compatible?(@context_store)
|
|
24
|
+
raise ArgumentError, "context store does not implement the Chronos context-store port"
|
|
25
|
+
end
|
|
22
26
|
@transport = options[:transport] || Adapters::NetHttpTransport.new(config, @logger)
|
|
23
27
|
unless Ports::Transport.compatible?(@transport)
|
|
24
28
|
raise ArgumentError, "transport does not implement the Chronos transport port"
|
|
25
29
|
end
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
pipeline_options = {}
|
|
31
|
+
pipeline_options[:queue] = options[:queue] if options[:queue]
|
|
32
|
+
pipeline_options[:worker_pool] = options[:worker_pool] if options[:worker_pool]
|
|
33
|
+
@delivery_pipeline = options[:delivery_pipeline] || Application::DeliveryPipeline.new(
|
|
34
|
+
config,
|
|
35
|
+
@transport,
|
|
36
|
+
@logger,
|
|
37
|
+
pipeline_options
|
|
38
|
+
)
|
|
39
|
+
@capture = options[:capture] || Application::CaptureException.new(config, @delivery_pipeline, @logger)
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def notify(exception, context = {})
|
|
32
|
-
@capture.call(exception, context)
|
|
43
|
+
@capture.call(exception, context_for_capture(context))
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
def notify_sync(exception, context = {})
|
|
36
|
-
@capture.call_sync(exception, context)
|
|
47
|
+
@capture.call_sync(exception, context_for_capture(context))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_context(context = {}, &block)
|
|
51
|
+
@context_store.with_context(context, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_breadcrumb(attributes = {})
|
|
55
|
+
context = @context_store.get
|
|
56
|
+
buffer = context[:__chronos_breadcrumbs]
|
|
57
|
+
buffer ||= Core::BreadcrumbBuffer.new(@config.breadcrumb_capacity, @config.breadcrumb_max_bytes)
|
|
58
|
+
buffer.add(attributes)
|
|
59
|
+
@context_store.set(context.merge(:__chronos_breadcrumbs => buffer))
|
|
60
|
+
true
|
|
61
|
+
rescue StandardError => error
|
|
62
|
+
@logger.warn("Chronos breadcrumb failed: #{error.class}")
|
|
63
|
+
false
|
|
37
64
|
end
|
|
38
65
|
|
|
39
66
|
def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
40
|
-
@
|
|
67
|
+
@delivery_pipeline.flush(timeout)
|
|
41
68
|
rescue StandardError => error
|
|
42
69
|
@logger.warn("Chronos flush failed: #{error.class}")
|
|
43
70
|
false
|
|
44
71
|
end
|
|
45
72
|
|
|
46
73
|
def close(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
47
|
-
@
|
|
74
|
+
@delivery_pipeline.close(timeout)
|
|
48
75
|
rescue StandardError => error
|
|
49
76
|
@logger.warn("Chronos close failed: #{error.class}")
|
|
50
77
|
false
|
|
51
78
|
end
|
|
52
79
|
|
|
53
80
|
def diagnostics
|
|
54
|
-
@
|
|
81
|
+
details = @delivery_pipeline.diagnostics
|
|
82
|
+
details[:queue].merge(details)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def build_context_store(strategy)
|
|
88
|
+
return Adapters::ThreadLocalContextStore.new if strategy == :thread_local
|
|
89
|
+
|
|
90
|
+
strategy
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def context_for_capture(additional)
|
|
94
|
+
merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
|
|
95
|
+
buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
|
|
96
|
+
if buffer.respond_to?(:to_a)
|
|
97
|
+
merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
|
|
98
|
+
end
|
|
99
|
+
merged
|
|
100
|
+
rescue StandardError
|
|
101
|
+
context_hash(additional)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deep_merge(left, right)
|
|
105
|
+
left.merge(right) do |_key, old_value, new_value|
|
|
106
|
+
old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def context_hash(value)
|
|
111
|
+
value.is_a?(Hash) ? value : {}
|
|
55
112
|
end
|
|
56
113
|
end
|
|
57
114
|
end
|
|
@@ -2,10 +2,10 @@ module Chronos
|
|
|
2
2
|
module Application
|
|
3
3
|
# Orchestrates exception normalization, serialization, queueing, and delivery.
|
|
4
4
|
#
|
|
5
|
-
# @responsibility Execute the
|
|
5
|
+
# @responsibility Execute the exception capture pipeline for manual and integration callers.
|
|
6
6
|
# @motivation Keep the public facade and transport adapters free of use-case logic.
|
|
7
|
-
# @limits
|
|
8
|
-
# @collaborators NoticeBuilder, PayloadSerializer,
|
|
7
|
+
# @limits Framework hooks remain separate integration adapters.
|
|
8
|
+
# @collaborators NoticeBuilder, PayloadSerializer, and DeliveryPipeline.
|
|
9
9
|
# @thread_safety Collaborators are immutable or synchronized; calls may run concurrently.
|
|
10
10
|
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
|
|
11
11
|
# @example
|
|
@@ -13,20 +13,25 @@ module Chronos
|
|
|
13
13
|
# @errors All internal StandardError failures are contained and logged.
|
|
14
14
|
# @performance Local work is bounded before asynchronous queue insertion.
|
|
15
15
|
class CaptureException
|
|
16
|
-
def initialize(config,
|
|
16
|
+
def initialize(config, delivery_pipeline, logger = nil, options = {})
|
|
17
17
|
@config = config
|
|
18
|
-
@
|
|
19
|
-
@transport = transport
|
|
18
|
+
@delivery_pipeline = delivery_pipeline
|
|
20
19
|
@logger = logger || Internal::SafeLogger.new(config.logger)
|
|
21
20
|
@notice_builder = options[:notice_builder] || Core::NoticeBuilder.new(config)
|
|
22
|
-
@serializer = options[:serializer] || Core::PayloadSerializer.new(
|
|
21
|
+
@serializer = options[:serializer] || Core::PayloadSerializer.new(
|
|
22
|
+
config,
|
|
23
|
+
nil,
|
|
24
|
+
:max_payload_size => proc { @delivery_pipeline.max_payload_size }
|
|
25
|
+
)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def call(exception, context = {})
|
|
26
29
|
return false unless capture_enabled?
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
@
|
|
31
|
+
notice = build_notice(exception, context)
|
|
32
|
+
return false unless @delivery_pipeline.capture_allowed?("exception", notice.fingerprint)
|
|
33
|
+
|
|
34
|
+
@delivery_pipeline.enqueue(@serializer.call(notice))
|
|
30
35
|
rescue StandardError => error
|
|
31
36
|
diagnose(error)
|
|
32
37
|
false
|
|
@@ -35,8 +40,10 @@ module Chronos
|
|
|
35
40
|
def call_sync(exception, context = {})
|
|
36
41
|
return false unless capture_enabled?
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
@
|
|
43
|
+
notice = build_notice(exception, context)
|
|
44
|
+
return false unless @delivery_pipeline.capture_allowed?("exception", notice.fingerprint)
|
|
45
|
+
|
|
46
|
+
@delivery_pipeline.deliver_sync(@serializer.call(notice))
|
|
40
47
|
rescue StandardError => error
|
|
41
48
|
diagnose(error)
|
|
42
49
|
false
|
|
@@ -48,9 +55,8 @@ module Chronos
|
|
|
48
55
|
@config.enabled_for_environment? && @config.error_notifications
|
|
49
56
|
end
|
|
50
57
|
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
@serializer.call(notice)
|
|
58
|
+
def build_notice(exception, context)
|
|
59
|
+
@notice_builder.call(exception, context)
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
def diagnose(error)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Small circuit breaker for retryable transport failures.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Stop delivery attempts temporarily after repeated failures.
|
|
6
|
+
# @motivation Prevent retry storms while the Chronos endpoint is unavailable.
|
|
7
|
+
# @limits It keeps no payload and permits only one half-open probe.
|
|
8
|
+
# @collaborators DeliveryPipeline and a monotonic clock.
|
|
9
|
+
# @thread_safety All transitions are protected by a mutex.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# transport.send_event(event) if breaker.allow_request?
|
|
13
|
+
# @errors Clock failures are contained by DeliveryPipeline.
|
|
14
|
+
# @performance Constant time with fixed memory.
|
|
15
|
+
class CircuitBreaker
|
|
16
|
+
def initialize(failure_threshold, reset_timeout, clock)
|
|
17
|
+
@failure_threshold = failure_threshold
|
|
18
|
+
@reset_timeout = reset_timeout
|
|
19
|
+
@clock = clock
|
|
20
|
+
@state = :closed
|
|
21
|
+
@failures = 0
|
|
22
|
+
@opened_at = nil
|
|
23
|
+
@probe_in_flight = false
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allow_request?
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
return true if @state == :closed
|
|
30
|
+
return false if @state == :half_open && @probe_in_flight
|
|
31
|
+
return false unless reset_elapsed?
|
|
32
|
+
|
|
33
|
+
@state = :half_open
|
|
34
|
+
@probe_in_flight = true
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_success
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@state = :closed
|
|
42
|
+
@failures = 0
|
|
43
|
+
@opened_at = nil
|
|
44
|
+
@probe_in_flight = false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record_failure
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
@failures += 1
|
|
51
|
+
if @state == :half_open || @failures >= @failure_threshold
|
|
52
|
+
@state = :open
|
|
53
|
+
@opened_at = @clock.call
|
|
54
|
+
end
|
|
55
|
+
@probe_in_flight = false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def state
|
|
60
|
+
@mutex.synchronize { @state }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def reset_elapsed?
|
|
66
|
+
@opened_at && (@clock.call - @opened_at >= @reset_timeout)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|