closeyourit-ruby 0.10.0 → 0.11.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/README.md +16 -1
- data/lib/closeyourit/background_worker.rb +14 -14
- data/lib/closeyourit/breadcrumb.rb +2 -2
- data/lib/closeyourit/breadcrumb_buffer.rb +2 -2
- data/lib/closeyourit/client.rb +24 -24
- data/lib/closeyourit/configuration.rb +104 -103
- data/lib/closeyourit/event.rb +6 -6
- data/lib/closeyourit/events/error_event.rb +16 -16
- data/lib/closeyourit/events/job_metric_event.rb +11 -8
- data/lib/closeyourit/events/log_event.rb +17 -17
- data/lib/closeyourit/events/message_event.rb +4 -4
- data/lib/closeyourit/events/performance_issue_event.rb +4 -4
- data/lib/closeyourit/events/slow_method_event.rb +6 -6
- data/lib/closeyourit/events/slow_query_event.rb +4 -4
- data/lib/closeyourit/instrumenter.rb +6 -6
- data/lib/closeyourit/job_context.rb +61 -0
- data/lib/closeyourit/line_cache.rb +4 -4
- data/lib/closeyourit/log_buffer.rb +10 -10
- data/lib/closeyourit/log_device.rb +18 -18
- data/lib/closeyourit/monitor.rb +2 -2
- data/lib/closeyourit/performance/request_profile.rb +5 -5
- data/lib/closeyourit/performance/rollup.rb +3 -3
- data/lib/closeyourit/rails/active_job_extension.rb +36 -31
- data/lib/closeyourit/rails/capture_exceptions.rb +3 -3
- data/lib/closeyourit/rails/error_subscriber.rb +4 -4
- data/lib/closeyourit/rails/log_broadcast.rb +12 -12
- data/lib/closeyourit/rails/net_http_patch.rb +22 -22
- data/lib/closeyourit/rails/query_source.rb +3 -3
- data/lib/closeyourit/rails/railtie.rb +29 -65
- data/lib/closeyourit/rails/request_body.rb +7 -7
- data/lib/closeyourit/rails/request_context.rb +27 -27
- data/lib/closeyourit/scope.rb +29 -29
- data/lib/closeyourit/scrubber.rb +50 -50
- data/lib/closeyourit/sidekiq/error_handler.rb +2 -2
- data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +39 -17
- data/lib/closeyourit/stats.rb +8 -8
- data/lib/closeyourit/subscribers/job_performance.rb +102 -24
- data/lib/closeyourit/subscribers/request_performance.rb +4 -4
- data/lib/closeyourit/subscribers/slow_query.rb +42 -20
- data/lib/closeyourit/trace_context.rb +22 -22
- data/lib/closeyourit/transport.rb +22 -22
- data/lib/closeyourit/usage_registry.rb +17 -16
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +125 -125
- metadata +2 -1
|
@@ -4,29 +4,29 @@ require "securerandom"
|
|
|
4
4
|
require_relative "../scrubber"
|
|
5
5
|
|
|
6
6
|
module CloseYourIt
|
|
7
|
-
#
|
|
8
|
-
# message/level/attributes/logger). `message`
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# Structured log entry sent to the /logs ingest (NOT Sentry format: logs are a stream with
|
|
8
|
+
# message/level/attributes/logger). `message` AND `attributes` go through the Scrubber (denylist +
|
|
9
|
+
# patterns) like ErrorEvent — a log can contain as much PII/secrets as an error. `trace_id` is
|
|
10
|
+
# frozen at CONSTRUCTION (request thread) → log↔error correlation for the same request even when
|
|
11
|
+
# the flush happens on a different timer thread.
|
|
12
12
|
class LogEvent < Event
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# Backend canonical levels (enum) + aliases of ::Logger-style names. Normalized HERE (single
|
|
14
|
+
# source) so every construction — via CloseYourIt.log, .logger or directly — yields a valid level.
|
|
15
|
+
# The index in LEVELS is also the level's numeric severity (debug=0 … fatal=4): the SAME map as
|
|
16
|
+
# dart/js, so threshold gating (`logs_min_level`) filters identically across SDKs (CYRB-6).
|
|
17
17
|
LEVELS = %w[debug info warning error fatal].freeze
|
|
18
18
|
LEVEL_ALIASES = { "warn" => "warning", "err" => "error", "unknown" => "fatal" }.freeze
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
20
|
+
# Normalizes any level (symbol/string, uppercase, alias) to the backend canonical name.
|
|
21
|
+
# Unknown → "info". Class method: single source reused by `CloseYourIt.log` for gating.
|
|
22
22
|
def self.normalize_level(level)
|
|
23
23
|
value = level.to_s.downcase
|
|
24
24
|
value = LEVEL_ALIASES.fetch(value, value)
|
|
25
25
|
LEVELS.include?(value) ? value : "info"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
28
|
+
# Numeric severity of the level (index in LEVELS), identical to the cross-SDK map. Used for the
|
|
29
|
+
# comparison with `logs_min_level` before building/sending the log.
|
|
30
30
|
def self.severity(level)
|
|
31
31
|
LEVELS.index(normalize_level(level))
|
|
32
32
|
end
|
|
@@ -38,9 +38,9 @@ module CloseYourIt
|
|
|
38
38
|
@attributes = attributes || {}
|
|
39
39
|
@logger = logger
|
|
40
40
|
@scrubber = Scrubber.new(configuration)
|
|
41
|
-
# trace_id
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
# trace_id captured HERE, on the request thread: the LogEvent is buffered and flushed on a
|
|
42
|
+
# different timer thread, where the current Scope belongs to ANOTHER request (or is empty) →
|
|
43
|
+
# reading it lazily in to_h would break log↔error correlation. We freeze it at construction.
|
|
44
44
|
@trace_id = CloseYourIt::Scope.current.trace_id
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -71,7 +71,7 @@ module CloseYourIt
|
|
|
71
71
|
@scrubber.filter_params(deep_stringify_keys(@attributes))
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
#
|
|
74
|
+
# Keys always strings (nested too): consistent with the JSON payload and the Scrubber denylist.
|
|
75
75
|
def deep_stringify_keys(value)
|
|
76
76
|
case value
|
|
77
77
|
when Hash then value.each_with_object({}) { |(key, val), acc| acc[key.to_s] = deep_stringify_keys(val) }
|
|
@@ -4,9 +4,9 @@ require "securerandom"
|
|
|
4
4
|
require_relative "../scrubber"
|
|
5
5
|
|
|
6
6
|
module CloseYourIt
|
|
7
|
-
#
|
|
8
|
-
# (`message.formatted` + level).
|
|
9
|
-
# Scrubber (
|
|
7
|
+
# Explicit diagnostic message (`CloseYourIt.capture_message`) in Sentry event format
|
|
8
|
+
# (`message.formatted` + level). Merges the current Scope like ErrorEvent. The message goes through
|
|
9
|
+
# the Scrubber (patterns) like ErrorEvent's `exception.message` — PII parity across events.
|
|
10
10
|
class MessageEvent < Event
|
|
11
11
|
def initialize(message, level:, configuration:)
|
|
12
12
|
super(configuration)
|
|
@@ -21,7 +21,7 @@ module CloseYourIt
|
|
|
21
21
|
"timestamp" => @occurred_at,
|
|
22
22
|
"platform" => "ruby",
|
|
23
23
|
"level" => @level,
|
|
24
|
-
#
|
|
24
|
+
# Log↔error correlation: same trace_id as the logs of the same request (parity with ErrorEvent).
|
|
25
25
|
"trace_id" => CloseYourIt::Scope.current.trace_id,
|
|
26
26
|
"environment" => environment,
|
|
27
27
|
"release" => @configuration.release,
|
|
@@ -4,10 +4,10 @@ require "securerandom"
|
|
|
4
4
|
require_relative "../event"
|
|
5
5
|
|
|
6
6
|
module CloseYourIt
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# `subtype`
|
|
10
|
-
#
|
|
7
|
+
# `kind=performance_issue` payload for the metrics pipeline (`/api/v1/projects/:id/metrics`).
|
|
8
|
+
# It is an aggregate VERDICT (N+1, slow request, slow external HTTP), not a raw metric:
|
|
9
|
+
# `subtype` qualifies it, `trace_id` correlates it with logs/errors of the same request. The SQL is
|
|
10
|
+
# already obfuscated (it is the profile fingerprint). Nil fields are omitted (slow_request has no sql).
|
|
11
11
|
class PerformanceIssueEvent < Event
|
|
12
12
|
def initialize(attrs, configuration)
|
|
13
13
|
super(configuration)
|
|
@@ -5,10 +5,10 @@ require_relative "../event"
|
|
|
5
5
|
require_relative "../scrubber"
|
|
6
6
|
|
|
7
7
|
module CloseYourIt
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# `kind=slow_method` payload for the metrics pipeline. By default only label + duration + location.
|
|
9
|
+
# Method arguments are sent ONLY with `capture_method_arguments` (opt-in, default OFF):
|
|
10
|
+
# positional by index, kwargs by name (sensitive key scrubbing), value via `inspect`
|
|
11
|
+
# truncated for JSON safety. See PDR §9.
|
|
12
12
|
class SlowMethodEvent < Event
|
|
13
13
|
def initialize(label, duration_ms, location, configuration, args: nil, kwargs: nil)
|
|
14
14
|
super(configuration)
|
|
@@ -41,8 +41,8 @@ module CloseYourIt
|
|
|
41
41
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
44
|
+
# Arguments — ONLY with capture_method_arguments (opt-in). Positional by index; kwargs by name with
|
|
45
|
+
# sensitive key scrubbing (denylist password/token/…). Value = truncated inspect (JSON-safe).
|
|
46
46
|
def arguments
|
|
47
47
|
return nil unless @configuration.capture_method_arguments
|
|
48
48
|
|
|
@@ -6,8 +6,8 @@ require_relative "../scrubber"
|
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
|
|
8
8
|
module CloseYourIt
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# `kind=slow_query` payload for the metrics pipeline (`/api/v1/projects/:id/metrics`).
|
|
10
|
+
# The SQL is obfuscated (binds excluded) — see PDR §9.
|
|
11
11
|
class SlowQueryEvent < Event
|
|
12
12
|
def initialize(payload, duration_ms, configuration)
|
|
13
13
|
super(configuration)
|
|
@@ -47,8 +47,8 @@ module CloseYourIt
|
|
|
47
47
|
connection.adapter_name.to_s.downcase
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
# (denylist password/token/…);
|
|
50
|
+
# Bind values — ONLY with capture_query_bindings (opt-in, default OFF). Scrubbed by column name
|
|
51
|
+
# (denylist password/token/…); the value is rendered as a string for JSON safety.
|
|
52
52
|
def bindings
|
|
53
53
|
return nil unless @configuration.capture_query_bindings
|
|
54
54
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
require_relative "events/slow_method_event"
|
|
4
4
|
|
|
5
5
|
module CloseYourIt
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Times blocks/methods with `CLOCK_MONOTONIC` and sends a `slow_method` when the duration exceeds
|
|
7
|
+
# the threshold. Arguments are sent only with `capture_method_arguments` (opt-in) — see SlowMethodEvent.
|
|
8
8
|
module Instrumenter
|
|
9
9
|
module_function
|
|
10
10
|
|
|
@@ -13,10 +13,10 @@ module CloseYourIt
|
|
|
13
13
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
14
14
|
yield
|
|
15
15
|
ensure
|
|
16
|
-
# `measure`
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# `measure` runs INSIDE the app's method (Monitor prepends): a failure here — threshold not
|
|
17
|
+
# configured, unreachable ingest — would raise in a caller that already had its result. It was
|
|
18
|
+
# the only unguarded entry point of the gem: now it is like all the others (CYRB-24). An
|
|
19
|
+
# exception from the measured block, instead, keeps propagating untouched.
|
|
20
20
|
begin
|
|
21
21
|
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
22
22
|
report(label, duration_ms, location, args: args, kwargs: kwargs)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module CloseYourIt
|
|
7
|
+
# Versioned attempt metadata. Missing clocks stay unknown, never an invented zero.
|
|
8
|
+
module JobContext
|
|
9
|
+
def self.build(framework:, name:, queue: nil, job_id: nil, attempt: nil, retry_count: :derive,
|
|
10
|
+
enqueued_at: nil, scheduled_at: nil, started_at: nil)
|
|
11
|
+
enqueued, scheduled, started = [ enqueued_at, scheduled_at, started_at ].map { |value| instant(value) }
|
|
12
|
+
due = enqueued && scheduled ? [ enqueued, scheduled ].max : enqueued
|
|
13
|
+
wait = (started - due) * 1000.0 if started && due
|
|
14
|
+
delay = (scheduled - enqueued) * 1000.0 if enqueued && scheduled
|
|
15
|
+
attempt = nil unless attempt.is_a?(Integer) && attempt.positive?
|
|
16
|
+
retry_count = attempt && attempt - 1 if retry_count == :derive
|
|
17
|
+
retry_count = nil unless retry_count.is_a?(Integer) && retry_count >= 0
|
|
18
|
+
{
|
|
19
|
+
"schema_version" => 1, "framework" => framework, "name" => bounded_text(name, 200) || "unknown",
|
|
20
|
+
"queue" => bounded_text(queue, 128), "job_id" => bounded_text(job_id, 256), "attempt" => attempt,
|
|
21
|
+
"retry_count" => retry_count, "attempt_outcome" => "unknown", "terminal" => nil,
|
|
22
|
+
"enqueued_at" => enqueued&.iso8601(6), "scheduled_at" => scheduled&.iso8601(6),
|
|
23
|
+
"started_at" => started&.iso8601(6), "duration_ms" => nil,
|
|
24
|
+
"queue_wait_ms" => wait && [ wait, 0.0 ].max,
|
|
25
|
+
"scheduled_delay_ms" => delay && [ delay, 0.0 ].max, "clock_skew" => !!(wait && wait.negative?)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.bounded_text(value, limit)
|
|
30
|
+
value if value.is_a?(String) && value.length.between?(1, limit) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.sidekiq_time(value, version:)
|
|
34
|
+
return value unless value.is_a?(Numeric)
|
|
35
|
+
|
|
36
|
+
version.to_s.split(".").first.to_i >= 8 ? value / 1000.0 : value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.instant(value)
|
|
40
|
+
case value
|
|
41
|
+
when Time then value.getutc
|
|
42
|
+
when Numeric then Time.at(value).utc if value.finite?
|
|
43
|
+
when String
|
|
44
|
+
if value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
|
|
45
|
+
DateTime.rfc3339(value)
|
|
46
|
+
Time.iso8601(value).utc
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue ArgumentError, RangeError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.log(context, configuration)
|
|
54
|
+
return unless configuration.monitor_jobs && configuration.job_lifecycle_logs
|
|
55
|
+
|
|
56
|
+
CloseYourIt.log(:info, "Background job attempt finished", logger: "closeyourit.jobs", contexts: { job: context })
|
|
57
|
+
rescue StandardError
|
|
58
|
+
CloseYourIt.internal_logger.warn("CloseYourIt job lifecycle emission failed")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
|
-
#
|
|
5
|
-
# (pre_context/context_line/post_context). Bounded:
|
|
6
|
-
#
|
|
4
|
+
# In-process cache of source file lines, for the frames' context lines
|
|
5
|
+
# (pre_context/context_line/post_context). Bounded: beyond MAX_FILES it is emptied entirely —
|
|
6
|
+
# simple and sufficient (the files of an app in error are few and recurring). Thread-safe.
|
|
7
7
|
module LineCache
|
|
8
8
|
MAX_FILES = 200
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ module CloseYourIt
|
|
|
11
11
|
@mutex = Mutex.new
|
|
12
12
|
|
|
13
13
|
class << self
|
|
14
|
-
#
|
|
14
|
+
# The file's lines (chomped), or nil if unreadable or a synthetic path ("(eval)", "(irb)").
|
|
15
15
|
def lines(path)
|
|
16
16
|
return nil if path.nil? || path.empty? || path.start_with?("(")
|
|
17
17
|
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
require "concurrent"
|
|
4
4
|
|
|
5
5
|
module CloseYourIt
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Thread-safe in-memory log buffer: collects LogEvents and flushes them in batches to /logs when
|
|
7
|
+
# they reach `logs_batch_size`, when `logs_flush_interval` expires (timer), or at shutdown.
|
|
8
|
+
# Cuts HTTP requests — logs are high-volume, unlike one-to-one errors/metrics.
|
|
9
9
|
class LogBuffer
|
|
10
|
-
attr_reader :timer #
|
|
10
|
+
attr_reader :timer # exposed for tests (checks the configured interval)
|
|
11
11
|
|
|
12
12
|
def initialize(client:, configuration:)
|
|
13
13
|
@client = client
|
|
@@ -33,12 +33,12 @@ module CloseYourIt
|
|
|
33
33
|
|
|
34
34
|
@client.flush_logs(batch)
|
|
35
35
|
rescue StandardError => e
|
|
36
|
-
#
|
|
37
|
-
# before_send
|
|
38
|
-
# (
|
|
36
|
+
# The flush runs both on the request thread (full batch) and on the timer thread: a to_h /
|
|
37
|
+
# before_send raising on ONE event must not propagate into the app nor kill the TimerTask
|
|
38
|
+
# (which would otherwise stop flushing → logs pile up and are lost). Swallowed and logged.
|
|
39
39
|
CloseYourIt.internal_logger.error("CloseYourIt log buffer: #{e.class}: #{e.message}")
|
|
40
|
-
#
|
|
41
|
-
#
|
|
40
|
+
# The batch has already been drained: if flush_logs raises, those logs are lost. Count them as
|
|
41
|
+
# drops so the failure is not silent (CYRB-12). `batch.to_a` is nil-safe.
|
|
42
42
|
batch.to_a.size.times do
|
|
43
43
|
CloseYourIt.stats.increment(:dropped)
|
|
44
44
|
CloseYourIt.notify_diagnostic(:drop, reason: :error)
|
|
@@ -60,7 +60,7 @@ module CloseYourIt
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
#
|
|
63
|
+
# Starts the periodic flush timer on the first entry (only once).
|
|
64
64
|
def ensure_timer
|
|
65
65
|
return if @timer
|
|
66
66
|
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
|
-
#
|
|
5
|
-
# `CloseYourIt.emit_log` (→ ingest /logs).
|
|
6
|
-
# CloseYourIt.logger.warn("
|
|
7
|
-
# `warn`
|
|
4
|
+
# Logger-compatible object exposed as `CloseYourIt.logger`: every call forwards to
|
|
5
|
+
# `CloseYourIt.emit_log` (→ ingest /logs). Usable as the app's explicit logger, also with attributes:
|
|
6
|
+
# CloseYourIt.logger.warn("disk almost full", disk: "sda1")
|
|
7
|
+
# `warn` maps to the `warning` level (backend enum); supports blocks and `::Logger#add` as a drop-in.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# payments = CloseYourIt.logger.named("payments") # child
|
|
9
|
+
# The log source (the payload's `logger` field) is set ONLY via `.named` (child logger, dart/js
|
|
10
|
+
# parity) or the constructor — NEVER from a method keyword: so a `logger` key passed to a method
|
|
11
|
+
# stays a data attribute and is not hijacked as the source (CYRB-8).
|
|
12
|
+
# payments = CloseYourIt.logger.named("payments") # child with source "payments"
|
|
13
13
|
# payments.warn("retry", attempt: 3) # logger=payments, attributes={attempt:3}
|
|
14
|
-
# CloseYourIt.logger.warn("
|
|
14
|
+
# CloseYourIt.logger.warn("disk full", logger: "/dev/sda1") # attributes={logger:"/dev/sda1"}
|
|
15
15
|
class LogDevice
|
|
16
|
-
#
|
|
16
|
+
# ::Logger numeric severities → CloseYourIt levels (UNKNOWN→fatal).
|
|
17
17
|
SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
|
|
18
18
|
|
|
19
|
-
# `source` =
|
|
19
|
+
# `source` = name of the log source (the payload's `logger` field). Prefer `.named` in apps.
|
|
20
20
|
def initialize(source = nil)
|
|
21
21
|
@source = normalize_source(source)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# Child logger
|
|
25
|
-
#
|
|
24
|
+
# Child logger with an explicit source (dart/js parity). Returns a NEW LogDevice: the logger it is
|
|
25
|
+
# called on stays unchanged (immutable), like the cross-SDK child loggers.
|
|
26
26
|
# CloseYourIt.logger.named("payments").info("retry", attempt: 3)
|
|
27
27
|
def named(source)
|
|
28
28
|
self.class.new(source)
|
|
@@ -39,7 +39,7 @@ module CloseYourIt
|
|
|
39
39
|
message
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
#
|
|
42
|
+
# Compatible with ::Logger#add(severity, message = nil, progname = nil).
|
|
43
43
|
def add(severity, message = nil, progname = nil, &block)
|
|
44
44
|
write(SEVERITY_LEVELS.fetch(severity.to_i, "info"), message || progname, {}, &block)
|
|
45
45
|
end
|
|
@@ -48,16 +48,16 @@ module CloseYourIt
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
50
|
def write(level, message, attributes, &block)
|
|
51
|
-
# Gate
|
|
51
|
+
# Gate BEFORE the block: `logger.debug { expensive_dump }` does not evaluate the block when logs are off.
|
|
52
52
|
return unless CloseYourIt.logs_active?
|
|
53
53
|
|
|
54
54
|
message = block.call if block
|
|
55
|
-
#
|
|
56
|
-
#
|
|
55
|
+
# Source and attributes kept apart: the `attributes` (including any `logger` key) stay data;
|
|
56
|
+
# the source is ONLY `@source` (via `.named`/constructor) → no collision (CYRB-8).
|
|
57
57
|
CloseYourIt.emit_log(level, message, source: @source, attributes: attributes)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
#
|
|
60
|
+
# Empty/blank source → no source (nil); otherwise a string (the `logger` field is textual).
|
|
61
61
|
def normalize_source(source)
|
|
62
62
|
return nil if source.nil?
|
|
63
63
|
|
data/lib/closeyourit/monitor.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "instrumenter"
|
|
4
4
|
|
|
5
5
|
module CloseYourIt
|
|
6
|
-
# Macro
|
|
6
|
+
# Macro to instrument a method automatically:
|
|
7
7
|
#
|
|
8
8
|
# class Report
|
|
9
9
|
# include CloseYourIt::Monitor
|
|
@@ -11,7 +11,7 @@ module CloseYourIt
|
|
|
11
11
|
# monitor :generate
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
|
-
#
|
|
14
|
+
# Wraps the method via `Module#prepend` timing it, without changing its signature/result.
|
|
15
15
|
module Monitor
|
|
16
16
|
def self.included(base)
|
|
17
17
|
base.extend(ClassMethods)
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
4
|
module Performance
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Per-request accumulator (lives in the Scope, reset at the end of the request). Counts queries,
|
|
6
|
+
# groups them by [obfuscated SQL fingerprint, call site] (the prosopite pattern for N+1) and keeps
|
|
7
|
+
# the external HTTP calls. Pure in-memory state: Performance::Rollup computes the verdict.
|
|
8
8
|
class RequestProfile
|
|
9
|
-
#
|
|
9
|
+
# Memory guard: cap on the distinct groups/calls tracked (the total count stays exact).
|
|
10
10
|
MAX_GROUPS = 1000
|
|
11
11
|
MAX_EXTERNAL = 500
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ module CloseYourIt
|
|
|
19
19
|
@external_calls = []
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# A non-system query. Cached queries are not DB round-trips → they do not count for N+1.
|
|
23
23
|
def add_query(fingerprint:, source:, duration_ms:, cached: false)
|
|
24
24
|
return if cached
|
|
25
25
|
|
|
@@ -4,8 +4,8 @@ require_relative "../events/performance_issue_event"
|
|
|
4
4
|
|
|
5
5
|
module CloseYourIt
|
|
6
6
|
module Performance
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Turns a RequestProfile (+ request duration/route) into 0..N PerformanceIssueEvent verdicts.
|
|
8
|
+
# Thresholds live in the Configuration. Detection on the client; dedup/alerts on the backend.
|
|
9
9
|
class Rollup
|
|
10
10
|
def self.call(...) = new(...).call
|
|
11
11
|
|
|
@@ -28,7 +28,7 @@ module CloseYourIt
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# One verdict for every [fingerprint, call-site] group that ran more than n_plus_one_threshold times.
|
|
32
32
|
def n_plus_one_events
|
|
33
33
|
@profile.query_groups.values.filter_map do |group|
|
|
34
34
|
next unless group[:count] > @config.n_plus_one_threshold
|
|
@@ -2,34 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
4
|
module Rails
|
|
5
|
-
#
|
|
6
|
-
# (
|
|
7
|
-
#
|
|
5
|
+
# Included in ActiveJob::Base (via the railtie's `on_load(:active_job)`): captures job errors
|
|
6
|
+
# (otherwise lost) with the job context. The logic lives in `.monitor`/`.report_discarded` so it
|
|
7
|
+
# is testable without ActiveSupport/ActiveJob.
|
|
8
8
|
#
|
|
9
|
-
# CYRB-19: `around_perform`
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
9
|
+
# CYRB-19: `around_perform` runs INSIDE `perform_now`, while `retry_on`/`discard_on` (rescue_from)
|
|
10
|
+
# are evaluated OUTSIDE, after the callbacks. Capturing in around_perform therefore means reporting
|
|
11
|
+
# EVERY attempt — even those that will be retried successfully — because the reporter sees the
|
|
12
|
+
# error before the retry can silence it, and every attempt raises a new instance (the internal,
|
|
13
|
+
# per-instance dedup does not kick in). On ActiveJob 7.1+ we delegate reporting to `after_discard`,
|
|
14
|
+
# which Rails calls on final failure (retry_on exhausted or unhandled exception), never on the
|
|
15
|
+
# attempts `retry_on` retries → a single occurrence per job.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
# - `after_discard`
|
|
19
|
-
#
|
|
20
|
-
# -
|
|
21
|
-
# `after_discard`
|
|
22
|
-
#
|
|
23
|
-
#
|
|
17
|
+
# Known limits (by ActiveJob's design, not the client's):
|
|
18
|
+
# - `after_discard` does NOT fire for errors caught by a custom `rescue_from`: they are handled by
|
|
19
|
+
# the app, so we no longer report them as "unhandled" (we used to, improperly).
|
|
20
|
+
# - ADAPTER-level retries (e.g. Sidekiq) WITHOUT `retry_on` re-raise the unhandled error:
|
|
21
|
+
# `after_discard` fires on every execution, so per-attempt dedup does not apply there
|
|
22
|
+
# (behavior unchanged from before).
|
|
23
|
+
# On versions without `after_discard` we keep the legacy fallback (capture in around_perform).
|
|
24
24
|
module ActiveJobExtension
|
|
25
|
-
# Scope
|
|
26
|
-
# (
|
|
27
|
-
#
|
|
25
|
+
# Scope enriched during `perform`, handed over to `after_discard`. Bound to the job INSTANCE
|
|
26
|
+
# (every retry creates a new, deserialized one) → it lives exactly as long as needed, no bleed
|
|
27
|
+
# between jobs or threads; the end-of-`perform` reset only detaches it from storage, it does not
|
|
28
|
+
# mutate the object.
|
|
28
29
|
STASHED_SCOPE_IVAR = :@__closeyourit_stashed_scope
|
|
29
30
|
|
|
30
31
|
def self.included(base)
|
|
31
32
|
base.around_perform do |job, block|
|
|
32
33
|
CloseYourIt::Rails::ActiveJobExtension.monitor(job) { block.call }
|
|
34
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
35
|
+
job.instance_variable_set(:@__closeyourit_job_outcome, :raised)
|
|
36
|
+
raise
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
return unless base.respond_to?(:after_discard)
|
|
@@ -39,9 +43,10 @@ module CloseYourIt
|
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
46
|
+
# Runs the job enriching the scope with tags/context; resets the scope at the end of the job (no
|
|
47
|
+
# bleed between jobs on the same thread). On ActiveJob 7.1+ it does NOT capture the error
|
|
48
|
+
# (`after_discard` does, only on final failure): here it hands the scope to the job and re-raises.
|
|
49
|
+
# On the legacy fallback it captures.
|
|
45
50
|
def self.monitor(job)
|
|
46
51
|
return yield unless CloseYourIt.configuration.report_active_job_errors
|
|
47
52
|
|
|
@@ -60,10 +65,10 @@ module CloseYourIt
|
|
|
60
65
|
end
|
|
61
66
|
end
|
|
62
67
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
68
|
+
# `after_discard` hook (ActiveJob 7.1+): the job has failed for good, so we report the error ONLY
|
|
69
|
+
# once (handled:false). We restore the scope enriched during `perform` (handed over by `monitor`)
|
|
70
|
+
# so the report keeps the breadcrumbs/tags/contexts collected in the job; `apply_job_scope`
|
|
71
|
+
# refreshes the standard fields with the final `executions` without losing the custom ones.
|
|
67
72
|
def self.report_discarded(job, exception)
|
|
68
73
|
return unless CloseYourIt.configuration.report_active_job_errors
|
|
69
74
|
|
|
@@ -78,8 +83,8 @@ module CloseYourIt
|
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
81
|
-
# true
|
|
82
|
-
#
|
|
86
|
+
# true when ActiveJob exposes `after_discard` (7.1+): reporting is delegated there, so `monitor`
|
|
87
|
+
# does not capture intermediate attempts. false → legacy fallback (capture in around_perform).
|
|
83
88
|
def self.report_on_discard?(job)
|
|
84
89
|
job.class.respond_to?(:after_discard)
|
|
85
90
|
end
|
|
@@ -93,8 +98,8 @@ module CloseYourIt
|
|
|
93
98
|
context["executions"] = job.executions if job.respond_to?(:executions)
|
|
94
99
|
CloseYourIt.set_context("active_job", context) unless context.empty?
|
|
95
100
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
101
|
+
# A stable trace_id per execution (job_id is unique per ActiveJob run): logs and error of the
|
|
102
|
+
# same job share the trace_id → log↔error correlation outside the request cycle too.
|
|
98
103
|
CloseYourIt::Scope.current.trace_id = job.job_id if job.respond_to?(:job_id)
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
4
|
module Rails
|
|
5
|
-
# Rack middleware:
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Rack middleware: captures unhandled exceptions, sends them to CloseYourIt
|
|
6
|
+
# and **re-raises** them (the app keeps handling them as before). Pure Rack, no
|
|
7
|
+
# Rails dependency → testable in isolation.
|
|
8
8
|
class CaptureExceptions
|
|
9
9
|
def initialize(app)
|
|
10
10
|
@app = app
|