nurse_andrea 0.1.7 → 0.2.3
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/lib/nurse_andrea/DATA_PRIVACY_POLICY.rb +29 -0
- data/lib/nurse_andrea/component_telemetry.rb +113 -0
- data/lib/nurse_andrea/configuration.rb +19 -6
- data/lib/nurse_andrea/deploy.rb +35 -0
- data/lib/nurse_andrea/instrumentation_subscriber.rb +177 -0
- data/lib/nurse_andrea/job_instrumentation.rb +52 -0
- data/lib/nurse_andrea/managed_service_scanner.rb +51 -0
- data/lib/nurse_andrea/memory_sampler.rb +55 -0
- data/lib/nurse_andrea/metrics_shipper.rb +17 -2
- data/lib/nurse_andrea/platform_detector.rb +40 -0
- data/lib/nurse_andrea/query_subscriber.rb +49 -0
- data/lib/nurse_andrea/queue_depth_reporter.rb +63 -0
- data/lib/nurse_andrea/railtie.rb +26 -0
- data/lib/nurse_andrea/sanitizer.rb +45 -0
- data/lib/nurse_andrea/self_filter.rb +39 -0
- data/lib/nurse_andrea/version.rb +1 -1
- data/lib/nurse_andrea.rb +31 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae308e57dd4f7a8bf6182f34e49a8223cc695448e9a0ef0146b37396e6deea19
|
|
4
|
+
data.tar.gz: 7323b870d40f057ac56130b11061ca84bb9d9db9f97f61697c1234d392df296b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4848f27cff94597a94a21b6fe366da00c547831aad652717e59a41255d25005142bf6a1b8fb9181f37a7358ee88e08e4e597b5e1c83cdff8da052ea716c2b583
|
|
7
|
+
data.tar.gz: f01a8a679ddcd4be389125c43ca2cfeca6703acc5dcf29f53a392896406f6384e87aaba578d24fd86d4a8e474400779c47dd2e02b02e3b751131458a604c7ee2
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# NURSEANDREA DATA PRIVACY POLICY — INVIOLABLE
|
|
3
|
+
# ============================================================
|
|
4
|
+
#
|
|
5
|
+
# This SDK reads environment variables (DATABASE_URL, REDIS_URL,
|
|
6
|
+
# etc.) and framework instrumentation hooks to detect secondary
|
|
7
|
+
# components. It NEVER transmits:
|
|
8
|
+
#
|
|
9
|
+
# - Raw environment variable values
|
|
10
|
+
# - Connection strings or URLs
|
|
11
|
+
# - Credentials (usernames, passwords, API tokens)
|
|
12
|
+
# - Hostnames, IP addresses, or ports
|
|
13
|
+
# - Database names or paths
|
|
14
|
+
#
|
|
15
|
+
# Only derived metadata is transmitted to NurseAndrea servers:
|
|
16
|
+
#
|
|
17
|
+
# - type (e.g. "database", "cache", "queue")
|
|
18
|
+
# - tech (e.g. "postgresql", "redis")
|
|
19
|
+
# - provider (e.g. "railway", "neon", "upstash")
|
|
20
|
+
# - source (e.g. "env_detection", "hook_subscription")
|
|
21
|
+
# - variable_name (e.g. "DATABASE_URL" — the name, not the value)
|
|
22
|
+
#
|
|
23
|
+
# The Sanitizer module enforces this policy. All discovery records
|
|
24
|
+
# pass through the Sanitizer before transmission. Any field not on
|
|
25
|
+
# the allowlist is stripped.
|
|
26
|
+
#
|
|
27
|
+
# This policy is a commitment to our customers. Violating it is
|
|
28
|
+
# a shipping-blocking defect.
|
|
29
|
+
# ============================================================
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# PRIVACY: This file aggregates framework hook data in-process.
|
|
2
|
+
# Only counts and durations are shipped — no query text, no keys,
|
|
3
|
+
# no raw data. See DATA_PRIVACY_POLICY.rb for the full policy.
|
|
4
|
+
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module NurseAndrea
|
|
8
|
+
class ComponentTelemetry
|
|
9
|
+
attr_reader :db, :cache, :jobs
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
reset!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record_query(duration_ms:, table: nil)
|
|
16
|
+
@db[:query_count] += 1
|
|
17
|
+
@db[:total_duration_ms] += duration_ms
|
|
18
|
+
@db[:slow_query_count] += 1 if duration_ms > 100
|
|
19
|
+
@db[:tables_accessed].add(table) if table.present?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_transaction(outcome:)
|
|
23
|
+
@db[:transaction_count] += 1
|
|
24
|
+
@db[:rollback_count] += 1 if outcome.to_s == "rollback"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_cache_read(hit:)
|
|
28
|
+
@cache[:read_count] += 1
|
|
29
|
+
hit ? @cache[:hit_count] += 1 : @cache[:miss_count] += 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record_cache_write
|
|
33
|
+
@cache[:write_count] += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def record_cache_delete
|
|
37
|
+
@cache[:delete_count] += 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_job_complete(duration_ms:, queue_name: nil)
|
|
41
|
+
@jobs[:complete_count] += 1
|
|
42
|
+
@jobs[:total_duration_ms] += duration_ms
|
|
43
|
+
@jobs[:queue_names].add(queue_name) if queue_name.present?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_job_enqueue(queue_name: nil)
|
|
47
|
+
@jobs[:enqueue_count] += 1
|
|
48
|
+
@jobs[:queue_names].add(queue_name) if queue_name.present?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record_job_fail(queue_name: nil)
|
|
52
|
+
@jobs[:fail_count] += 1
|
|
53
|
+
@jobs[:queue_names].add(queue_name) if queue_name.present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def snapshot_and_reset!
|
|
57
|
+
metrics = []
|
|
58
|
+
|
|
59
|
+
if @db[:query_count] > 0
|
|
60
|
+
metrics << {
|
|
61
|
+
type: "database", tech: @db[:tech],
|
|
62
|
+
interval_ms: NurseAndrea.config.hook_interval_ms,
|
|
63
|
+
query_count: @db[:query_count],
|
|
64
|
+
slow_query_count: @db[:slow_query_count],
|
|
65
|
+
total_duration_ms: @db[:total_duration_ms].round(2),
|
|
66
|
+
tables_accessed: @db[:tables_accessed].to_a,
|
|
67
|
+
transaction_count: @db[:transaction_count],
|
|
68
|
+
rollback_count: @db[:rollback_count],
|
|
69
|
+
error_count: 0
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if @cache[:read_count] > 0 || @cache[:write_count] > 0
|
|
74
|
+
metrics << {
|
|
75
|
+
type: "cache", tech: @cache[:tech],
|
|
76
|
+
interval_ms: NurseAndrea.config.hook_interval_ms,
|
|
77
|
+
read_count: @cache[:read_count], write_count: @cache[:write_count],
|
|
78
|
+
hit_count: @cache[:hit_count], miss_count: @cache[:miss_count],
|
|
79
|
+
delete_count: @cache[:delete_count],
|
|
80
|
+
total_duration_ms: 0, error_count: 0
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if @jobs[:enqueue_count] > 0 || @jobs[:complete_count] > 0
|
|
85
|
+
metrics << {
|
|
86
|
+
type: "queue", tech: @jobs[:tech],
|
|
87
|
+
interval_ms: NurseAndrea.config.hook_interval_ms,
|
|
88
|
+
enqueue_count: @jobs[:enqueue_count],
|
|
89
|
+
complete_count: @jobs[:complete_count],
|
|
90
|
+
fail_count: @jobs[:fail_count],
|
|
91
|
+
total_duration_ms: @jobs[:total_duration_ms].round(2),
|
|
92
|
+
queue_names: @jobs[:queue_names].to_a,
|
|
93
|
+
retry_count: 0, error_count: 0
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
reset!
|
|
98
|
+
metrics
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def reset!
|
|
104
|
+
@db = { tech: "unknown", query_count: 0, slow_query_count: 0,
|
|
105
|
+
total_duration_ms: 0.0, tables_accessed: Set.new,
|
|
106
|
+
transaction_count: 0, rollback_count: 0 }
|
|
107
|
+
@cache = { tech: "unknown", read_count: 0, write_count: 0,
|
|
108
|
+
hit_count: 0, miss_count: 0, delete_count: 0 }
|
|
109
|
+
@jobs = { tech: "unknown", enqueue_count: 0, complete_count: 0,
|
|
110
|
+
fail_count: 0, total_duration_ms: 0.0, queue_names: Set.new }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -3,7 +3,10 @@ module NurseAndrea
|
|
|
3
3
|
attr_accessor :api_key, :host, :timeout, :log_level, :batch_size,
|
|
4
4
|
:flush_interval, :backfill_hours, :log_file_path,
|
|
5
5
|
:enabled, :debug, :service_name,
|
|
6
|
-
:sdk_version, :sdk_language
|
|
6
|
+
:sdk_version, :sdk_language,
|
|
7
|
+
:hooks_enabled, :hooks_database, :hooks_cache,
|
|
8
|
+
:hooks_jobs, :hooks_mailer, :hook_interval_ms,
|
|
9
|
+
:platform_detection, :service_discovery, :auto_connect
|
|
7
10
|
|
|
8
11
|
LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
9
12
|
DEFAULT_HOST = "https://nurseandrea.io"
|
|
@@ -16,11 +19,20 @@ module NurseAndrea
|
|
|
16
19
|
@flush_interval = 10
|
|
17
20
|
@backfill_hours = 24
|
|
18
21
|
@log_file_path = nil
|
|
19
|
-
@enabled
|
|
20
|
-
@debug
|
|
21
|
-
@service_name
|
|
22
|
-
@sdk_version
|
|
23
|
-
@sdk_language
|
|
22
|
+
@enabled = true
|
|
23
|
+
@debug = false
|
|
24
|
+
@service_name = default_service_name
|
|
25
|
+
@sdk_version = NurseAndrea::VERSION
|
|
26
|
+
@sdk_language = "ruby"
|
|
27
|
+
@hooks_enabled = true
|
|
28
|
+
@hooks_database = true
|
|
29
|
+
@hooks_cache = true
|
|
30
|
+
@hooks_jobs = true
|
|
31
|
+
@hooks_mailer = true
|
|
32
|
+
@hook_interval_ms = 10_000
|
|
33
|
+
@platform_detection = true
|
|
34
|
+
@service_discovery = true
|
|
35
|
+
@auto_connect = false
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
alias_method :token, :api_key
|
|
@@ -31,6 +43,7 @@ module NurseAndrea
|
|
|
31
43
|
def metrics_url = "#{normalised_host}/api/v1/metrics"
|
|
32
44
|
def traces_url = "#{normalised_host}/api/v1/traces"
|
|
33
45
|
def handshake_url = "#{normalised_host}/api/v1/handshake"
|
|
46
|
+
def deploy_url = "#{normalised_host}/api/v1/deploy"
|
|
34
47
|
|
|
35
48
|
def enabled?
|
|
36
49
|
@enabled
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
# Public: ship a deploy event to the NurseAndrea backend so the
|
|
3
|
+
# dashboard can render it as a vertical marker on time-series charts
|
|
4
|
+
# and as a chip in the recent-deploys strip.
|
|
5
|
+
#
|
|
6
|
+
# Fire-and-forget: any failure (no token, network error, non-2xx) is
|
|
7
|
+
# logged in debug mode and swallowed so the host application never
|
|
8
|
+
# crashes from a deploy notification.
|
|
9
|
+
module Deploy
|
|
10
|
+
DESCRIPTION_LIMIT = 500
|
|
11
|
+
|
|
12
|
+
def self.call(version:, deployer: nil, environment: "production", description: nil)
|
|
13
|
+
return false unless NurseAndrea.config.valid?
|
|
14
|
+
return false if version.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
payload = {
|
|
17
|
+
version: version.to_s,
|
|
18
|
+
deployer: deployer,
|
|
19
|
+
environment: environment,
|
|
20
|
+
description: description.is_a?(String) ? description[0, DESCRIPTION_LIMIT] : description,
|
|
21
|
+
deployed_at: Time.now.utc.iso8601
|
|
22
|
+
}.compact
|
|
23
|
+
|
|
24
|
+
HttpClient.new.post(NurseAndrea.config.deploy_url, payload)
|
|
25
|
+
rescue => e
|
|
26
|
+
NurseAndrea.debug("[NurseAndrea] deploy() error: #{e.class}: #{e.message}")
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convenience top-level: NurseAndrea.deploy(version: "1.4.2")
|
|
32
|
+
def self.deploy(**kwargs)
|
|
33
|
+
Deploy.call(**kwargs)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# PRIVACY: This file subscribes to framework instrumentation hooks.
|
|
2
|
+
# Query text is aggregated (table names extracted) but never shipped raw.
|
|
3
|
+
# See DATA_PRIVACY_POLICY.rb for the full policy.
|
|
4
|
+
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module NurseAndrea
|
|
8
|
+
class InstrumentationSubscriber
|
|
9
|
+
SUBSCRIPTIONS = {
|
|
10
|
+
"sql.active_record" => :on_sql,
|
|
11
|
+
"transaction.active_record" => :on_transaction,
|
|
12
|
+
"cache_read.active_support" => :on_cache_read,
|
|
13
|
+
"cache_write.active_support" => :on_cache_write,
|
|
14
|
+
"cache_delete.active_support" => :on_cache_delete,
|
|
15
|
+
"perform.active_job" => :on_job_perform,
|
|
16
|
+
"enqueue.active_job" => :on_job_enqueue,
|
|
17
|
+
"deliver.action_mailer" => :on_mailer
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :telemetry, :discovered_components
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@telemetry = ComponentTelemetry.new
|
|
24
|
+
@discovered_components = Set.new
|
|
25
|
+
@subscribed = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subscribe_all
|
|
29
|
+
return if @subscribed
|
|
30
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
31
|
+
|
|
32
|
+
SUBSCRIPTIONS.each do |event_name, handler|
|
|
33
|
+
ActiveSupport::Notifications.monotonic_subscribe(event_name) do |event|
|
|
34
|
+
begin
|
|
35
|
+
send(handler, event)
|
|
36
|
+
rescue => e
|
|
37
|
+
NurseAndrea.debug("[InstrumentationSubscriber] #{handler} error: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@subscribed = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def on_sql(event)
|
|
48
|
+
return if event.payload[:name] == "SCHEMA"
|
|
49
|
+
return if event.payload[:name]&.start_with?("EXPLAIN")
|
|
50
|
+
|
|
51
|
+
conn = event.payload[:connection]
|
|
52
|
+
adapter = conn&.adapter_name rescue nil
|
|
53
|
+
|
|
54
|
+
# Skip platform's own ClickHouse queries — not the customer's infrastructure
|
|
55
|
+
return if adapter&.downcase&.include?("clickhouse")
|
|
56
|
+
|
|
57
|
+
tech = adapter_to_tech(adapter)
|
|
58
|
+
register_discovery("database", tech, connection: conn) if tech
|
|
59
|
+
|
|
60
|
+
table = extract_table(event.payload[:sql])
|
|
61
|
+
@telemetry.record_query(duration_ms: event.duration, table: table)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def on_transaction(event)
|
|
65
|
+
outcome = event.payload[:outcome]
|
|
66
|
+
@telemetry.record_transaction(outcome: outcome)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def on_cache_read(event)
|
|
70
|
+
tech = store_to_tech(event.payload[:store])
|
|
71
|
+
|
|
72
|
+
# Only register external cache stores as components
|
|
73
|
+
register_discovery("cache", tech) if tech
|
|
74
|
+
|
|
75
|
+
# Still record telemetry for all cache reads
|
|
76
|
+
@telemetry.record_cache_read(hit: event.payload[:hit])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on_cache_write(_event)
|
|
80
|
+
@telemetry.record_cache_write
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def on_cache_delete(_event)
|
|
84
|
+
@telemetry.record_cache_delete
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_job_perform(event)
|
|
88
|
+
adapter = detect_queue_adapter
|
|
89
|
+
register_discovery("queue", adapter)
|
|
90
|
+
@telemetry.record_job_complete(
|
|
91
|
+
duration_ms: event.duration,
|
|
92
|
+
queue_name: event.payload[:job]&.queue_name
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def on_job_enqueue(event)
|
|
97
|
+
@telemetry.record_job_enqueue(queue_name: event.payload[:job]&.queue_name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def on_mailer(_event)
|
|
101
|
+
register_discovery("external", "email")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def register_discovery(type, tech, connection: nil)
|
|
105
|
+
return if tech.nil? || tech.empty? || tech == "unknown"
|
|
106
|
+
return if self_referential?(connection)
|
|
107
|
+
|
|
108
|
+
key = "#{type}:#{tech}"
|
|
109
|
+
return if @discovered_components.include?(key)
|
|
110
|
+
|
|
111
|
+
@discovered_components.add(key)
|
|
112
|
+
NurseAndrea.component_discoveries << Sanitizer.sanitize_discovery(
|
|
113
|
+
type: type, tech: tech, provider: "unknown",
|
|
114
|
+
source: "hook_subscription", variable_name: nil
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when the SDK is running inside NurseAndrea itself, OR when
|
|
119
|
+
# the SQL event's connection points at NurseAndrea's own infra.
|
|
120
|
+
# Either way the discovery would be a self-reference, not a
|
|
121
|
+
# customer component. Process-level + connection-level checks are
|
|
122
|
+
# both consulted; the same module powers the env-scanner filter.
|
|
123
|
+
def self_referential?(connection)
|
|
124
|
+
return true if NurseAndrea::SelfFilter.platform_self?
|
|
125
|
+
return false unless connection
|
|
126
|
+
|
|
127
|
+
db_name = connection.current_database.to_s rescue ""
|
|
128
|
+
host = connection.pool&.db_config&.host.to_s rescue ""
|
|
129
|
+
NurseAndrea::SelfFilter.host_matches?(db_name, host)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def adapter_to_tech(adapter_name)
|
|
133
|
+
case adapter_name&.downcase
|
|
134
|
+
when "postgresql", "postgis" then "postgresql"
|
|
135
|
+
when "mysql2", "trilogy" then "mysql"
|
|
136
|
+
when "sqlite3" then "sqlite"
|
|
137
|
+
when "clickhouse" then nil
|
|
138
|
+
else adapter_name&.downcase.presence
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def store_to_tech(store_class)
|
|
143
|
+
case store_class.to_s
|
|
144
|
+
when /Redis/i then "redis"
|
|
145
|
+
when /Memcache/i then "memcached"
|
|
146
|
+
when /Memory/i then nil # In-process, not infrastructure
|
|
147
|
+
when /File/i then nil # Local filesystem, not infrastructure
|
|
148
|
+
when /Null/i then nil # No-op store
|
|
149
|
+
else nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def detect_queue_adapter
|
|
154
|
+
return "unknown" unless defined?(ActiveJob::Base)
|
|
155
|
+
adapter = ActiveJob::Base.queue_adapter.class.name rescue nil
|
|
156
|
+
case adapter
|
|
157
|
+
when /Sidekiq/i then "sidekiq"
|
|
158
|
+
when /SolidQueue/i then "solid_queue"
|
|
159
|
+
when /Resque/i then "resque"
|
|
160
|
+
when /DelayedJob/i then "delayed_job"
|
|
161
|
+
when /GoodJob/i then "good_job"
|
|
162
|
+
else "unknown"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_table(sql)
|
|
167
|
+
return nil if sql.nil? || sql.empty?
|
|
168
|
+
if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
|
|
169
|
+
$1
|
|
170
|
+
elsif sql =~ /\bINTO\s+["`]?(\w+)["`]?/i
|
|
171
|
+
$1
|
|
172
|
+
elsif sql =~ /\bUPDATE\s+["`]?(\w+)["`]?/i
|
|
173
|
+
$1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module NurseAndrea
|
|
4
|
+
module JobInstrumentation
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
around_perform :nurseandrea_instrument_job
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def nurseandrea_instrument_job
|
|
14
|
+
return yield unless NurseAndrea.config.enabled?
|
|
15
|
+
|
|
16
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
status = "completed"
|
|
18
|
+
error_class = nil
|
|
19
|
+
error_message = nil
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
yield
|
|
23
|
+
rescue => e
|
|
24
|
+
status = "failed"
|
|
25
|
+
error_class = e.class.name
|
|
26
|
+
error_message = e.message.to_s[0, 500]
|
|
27
|
+
raise
|
|
28
|
+
ensure
|
|
29
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
|
|
30
|
+
|
|
31
|
+
NurseAndrea::MetricsShipper.instance.enqueue(
|
|
32
|
+
name: "job.perform",
|
|
33
|
+
value: duration_ms,
|
|
34
|
+
unit: "ms",
|
|
35
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
36
|
+
tags: {
|
|
37
|
+
job_class: self.class.name,
|
|
38
|
+
queue_name: queue_name,
|
|
39
|
+
status: status,
|
|
40
|
+
error_class: error_class,
|
|
41
|
+
attempts: executions,
|
|
42
|
+
priority: priority
|
|
43
|
+
}.compact
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if NurseAndrea.config.debug
|
|
47
|
+
warn "[NurseAndrea::JobInstrumentation] #{self.class.name} #{status} in #{duration_ms}ms"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# PRIVACY: This file reads environment variable values to derive
|
|
2
|
+
# component metadata. Raw values never leave this process. Only
|
|
3
|
+
# derived metadata (type, tech, provider) is transmitted.
|
|
4
|
+
# See DATA_PRIVACY_POLICY.rb for the full policy.
|
|
5
|
+
|
|
6
|
+
module NurseAndrea
|
|
7
|
+
module ManagedServiceScanner
|
|
8
|
+
SCAN_MAP = {
|
|
9
|
+
"DATABASE_URL" => "database",
|
|
10
|
+
"DATABASE_PRIVATE_URL" => "database",
|
|
11
|
+
"REDIS_URL" => "cache",
|
|
12
|
+
"REDIS_PRIVATE_URL" => "cache",
|
|
13
|
+
"RABBITMQ_URL" => "queue",
|
|
14
|
+
"CLOUDAMQP_URL" => "queue",
|
|
15
|
+
"MONGODB_URI" => "database",
|
|
16
|
+
"MONGO_URL" => "database",
|
|
17
|
+
"ELASTICSEARCH_URL" => "search",
|
|
18
|
+
"KAFKA_BROKERS" => "queue"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.scan
|
|
22
|
+
# Skip env-based discovery entirely when the SDK is loaded inside
|
|
23
|
+
# NurseAndrea itself — every URL we'd find belongs to the platform's
|
|
24
|
+
# own infrastructure, not a customer component.
|
|
25
|
+
return [] if NurseAndrea::SelfFilter.platform_self?
|
|
26
|
+
|
|
27
|
+
discoveries = []
|
|
28
|
+
|
|
29
|
+
SCAN_MAP.each do |var_name, component_type|
|
|
30
|
+
url = ENV[var_name]
|
|
31
|
+
next if url.nil? || url.strip.empty?
|
|
32
|
+
next if NurseAndrea::SelfFilter.host_matches?(url)
|
|
33
|
+
|
|
34
|
+
tech = Sanitizer.extract_tech(url)
|
|
35
|
+
provider = Sanitizer.extract_provider(url)
|
|
36
|
+
|
|
37
|
+
raw = {
|
|
38
|
+
type: component_type,
|
|
39
|
+
tech: tech,
|
|
40
|
+
provider: provider,
|
|
41
|
+
source: "env_detection",
|
|
42
|
+
variable_name: var_name
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
discoveries << Sanitizer.sanitize_discovery(raw)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
discoveries.uniq { |d| [ d[:type], d[:tech], d[:provider] ] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class MemorySampler
|
|
3
|
+
INTERVAL_SECONDS = 30
|
|
4
|
+
|
|
5
|
+
def self.start
|
|
6
|
+
return @thread if @thread&.alive?
|
|
7
|
+
|
|
8
|
+
@thread = Thread.new do
|
|
9
|
+
loop do
|
|
10
|
+
sleep INTERVAL_SECONDS
|
|
11
|
+
sample_and_enqueue
|
|
12
|
+
rescue => e
|
|
13
|
+
NurseAndrea.debug("[NurseAndrea] MemorySampler error: #{e.message}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
@thread.abort_on_exception = false
|
|
17
|
+
@thread.name = "NurseAndrea::MemorySampler"
|
|
18
|
+
@thread
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.stop
|
|
22
|
+
@thread&.kill
|
|
23
|
+
@thread = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns RSS in bytes. Works on Linux (/proc) and macOS (ps).
|
|
27
|
+
def self.rss_bytes
|
|
28
|
+
if File.exist?("/proc/self/status")
|
|
29
|
+
line = File.readlines("/proc/self/status").find { |l| l.start_with?("VmRSS:") }
|
|
30
|
+
return nil unless line
|
|
31
|
+
kb = line.split[1].to_i
|
|
32
|
+
kb * 1024
|
|
33
|
+
else
|
|
34
|
+
kb = `ps -o rss= -p #{Process.pid}`.strip.to_i
|
|
35
|
+
return nil if kb == 0
|
|
36
|
+
kb * 1024
|
|
37
|
+
end
|
|
38
|
+
rescue
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.sample_and_enqueue
|
|
43
|
+
bytes = rss_bytes
|
|
44
|
+
return unless bytes && bytes > 0
|
|
45
|
+
|
|
46
|
+
MetricsShipper.instance.enqueue(
|
|
47
|
+
name: "process.memory.rss",
|
|
48
|
+
value: bytes,
|
|
49
|
+
unit: "bytes",
|
|
50
|
+
tags: { service: NurseAndrea.config.service_name },
|
|
51
|
+
timestamp: Time.now.utc.iso8601(3)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -62,9 +62,10 @@ module NurseAndrea
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def ship(metrics)
|
|
65
|
-
|
|
65
|
+
payload = {
|
|
66
66
|
sdk_version: NurseAndrea.config.sdk_version,
|
|
67
67
|
sdk_language: NurseAndrea.config.sdk_language,
|
|
68
|
+
platform: NurseAndrea.platform_context,
|
|
68
69
|
metrics: metrics.map { |m|
|
|
69
70
|
{
|
|
70
71
|
name: m[:name],
|
|
@@ -74,7 +75,21 @@ module NurseAndrea
|
|
|
74
75
|
occurred_at: m[:timestamp]
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
|
-
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Include component telemetry if available
|
|
81
|
+
component_metrics = NurseAndrea.instrumentation_subscriber
|
|
82
|
+
.telemetry
|
|
83
|
+
.snapshot_and_reset!
|
|
84
|
+
payload[:component_metrics] = component_metrics if component_metrics.any?
|
|
85
|
+
|
|
86
|
+
# Include component discoveries (flush once)
|
|
87
|
+
if NurseAndrea.component_discoveries.any?
|
|
88
|
+
payload[:component_discoveries] = NurseAndrea.component_discoveries.dup
|
|
89
|
+
NurseAndrea.component_discoveries.clear
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
HttpClient.new.post(NurseAndrea.config.metrics_url, payload)
|
|
78
93
|
end
|
|
79
94
|
end
|
|
80
95
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# PRIVACY: This file reads environment variable NAMES to detect
|
|
2
|
+
# the hosting platform. Only the platform name is transmitted.
|
|
3
|
+
# See DATA_PRIVACY_POLICY.rb for the full policy.
|
|
4
|
+
|
|
5
|
+
module NurseAndrea
|
|
6
|
+
module PlatformDetector
|
|
7
|
+
PLATFORMS = {
|
|
8
|
+
railway: -> { ENV.key?("RAILWAY_ENVIRONMENT") },
|
|
9
|
+
render: -> { ENV.key?("RENDER") },
|
|
10
|
+
fly: -> { ENV.key?("FLY_APP_NAME") },
|
|
11
|
+
heroku: -> { ENV.key?("DYNO") },
|
|
12
|
+
digitalocean: -> { ENV.key?("DIGITALOCEAN_APP_PLATFORM_COMPONENT_NAME") },
|
|
13
|
+
vercel: -> { ENV.key?("VERCEL") }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.detect
|
|
17
|
+
PLATFORMS.each { |name, check| return name.to_s if check.call }
|
|
18
|
+
"unknown"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.context
|
|
22
|
+
platform = detect
|
|
23
|
+
ctx = { platform: platform }
|
|
24
|
+
|
|
25
|
+
case platform
|
|
26
|
+
when "railway"
|
|
27
|
+
ctx[:region] = ENV["RAILWAY_REGION"] if ENV.key?("RAILWAY_REGION")
|
|
28
|
+
ctx[:environment] = ENV["RAILWAY_ENVIRONMENT"] if ENV.key?("RAILWAY_ENVIRONMENT")
|
|
29
|
+
when "render"
|
|
30
|
+
ctx[:region] = ENV["RENDER_REGION"] if ENV.key?("RENDER_REGION")
|
|
31
|
+
when "fly"
|
|
32
|
+
ctx[:region] = ENV["FLY_REGION"] if ENV.key?("FLY_REGION")
|
|
33
|
+
when "heroku"
|
|
34
|
+
ctx[:dyno_type] = ENV["DYNO"]&.gsub(/\.\d+$/, "") if ENV.key?("DYNO")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ctx
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class QuerySubscriber
|
|
3
|
+
SKIP_PATTERN = /\A(SHOW|SET|PRAGMA|SELECT.*schema_migrations|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|SELECT version)/i
|
|
4
|
+
SKIP_CONNECTIONS = %w[ClickhouseActiverecord Clickhouse].freeze
|
|
5
|
+
SLOW_THRESHOLD_MS = 100
|
|
6
|
+
|
|
7
|
+
def self.subscribe!
|
|
8
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |name, start, finish, id, payload|
|
|
9
|
+
next if payload[:name] == "SCHEMA"
|
|
10
|
+
|
|
11
|
+
# Only instrument PostgreSQL queries — skip ClickHouse
|
|
12
|
+
conn = payload[:connection]
|
|
13
|
+
conn_name = conn.is_a?(Class) ? conn.name.to_s : conn.class.name.to_s
|
|
14
|
+
next if SKIP_CONNECTIONS.any? { |c| conn_name.include?(c) }
|
|
15
|
+
# Also skip by adapter name if available
|
|
16
|
+
next if payload[:connection_id].to_s.include?("clickhouse")
|
|
17
|
+
|
|
18
|
+
# Skip queries targeting ClickHouse tables
|
|
19
|
+
sql_check = payload[:sql].to_s
|
|
20
|
+
next if sql_check.match?(/\b(metric_points|log_entries|job_metrics|spans)\b/i)
|
|
21
|
+
|
|
22
|
+
sql = payload[:sql].to_s.strip
|
|
23
|
+
next if sql.empty?
|
|
24
|
+
next if sql.match?(SKIP_PATTERN)
|
|
25
|
+
|
|
26
|
+
duration_ms = ((finish - start) * 1000).round(2)
|
|
27
|
+
timestamp = finish.utc.iso8601(3)
|
|
28
|
+
|
|
29
|
+
# Always ship slow queries
|
|
30
|
+
if duration_ms >= SLOW_THRESHOLD_MS
|
|
31
|
+
LogShipper.instance.enqueue(
|
|
32
|
+
level: "warn",
|
|
33
|
+
message: "SLOW QUERY took #{duration_ms.round}ms: #{sql.first(2000)}",
|
|
34
|
+
timestamp: timestamp
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Sample 10% of all queries for frequency tracking
|
|
39
|
+
if rand < 0.1
|
|
40
|
+
LogShipper.instance.enqueue(
|
|
41
|
+
level: "debug",
|
|
42
|
+
message: "#{duration_ms.round}ms: #{sql.first(2000)}",
|
|
43
|
+
timestamp: timestamp
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class QueueDepthReporter
|
|
3
|
+
# Auto-detects Solid Queue or Sidekiq and reports queue depths.
|
|
4
|
+
# Returns an array of metric hashes ready for MetricsShipper.
|
|
5
|
+
|
|
6
|
+
def self.report!
|
|
7
|
+
new.report!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def report!
|
|
11
|
+
metrics = []
|
|
12
|
+
now = Time.now.utc.iso8601(3)
|
|
13
|
+
|
|
14
|
+
if solid_queue?
|
|
15
|
+
metrics.concat(solid_queue_depths(now))
|
|
16
|
+
elsif sidekiq?
|
|
17
|
+
metrics.concat(sidekiq_depths(now))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
metrics.each { |m| NurseAndrea::MetricsShipper.instance.enqueue(m) }
|
|
21
|
+
metrics
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def solid_queue?
|
|
27
|
+
defined?(SolidQueue::Job)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sidekiq?
|
|
31
|
+
defined?(Sidekiq::Queue)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def solid_queue_depths(now)
|
|
35
|
+
queues = SolidQueue::Job
|
|
36
|
+
.where(finished_at: nil)
|
|
37
|
+
.group(:queue_name)
|
|
38
|
+
.count
|
|
39
|
+
|
|
40
|
+
queues.map do |queue_name, depth|
|
|
41
|
+
{
|
|
42
|
+
name: "queue.depth",
|
|
43
|
+
value: depth,
|
|
44
|
+
unit: "count",
|
|
45
|
+
timestamp: now,
|
|
46
|
+
tags: { queue_name: queue_name, backend: "solid_queue" }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sidekiq_depths(now)
|
|
52
|
+
Sidekiq::Queue.all.map do |queue|
|
|
53
|
+
{
|
|
54
|
+
name: "queue.depth",
|
|
55
|
+
value: queue.size,
|
|
56
|
+
unit: "count",
|
|
57
|
+
timestamp: now,
|
|
58
|
+
tags: { queue_name: queue.name, backend: "sidekiq" }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/nurse_andrea/railtie.rb
CHANGED
|
@@ -9,6 +9,7 @@ module NurseAndrea
|
|
|
9
9
|
Rails.logger = NurseAndrea::LogInterceptor.new(Rails.logger)
|
|
10
10
|
NurseAndrea::LogShipper.instance.start!
|
|
11
11
|
NurseAndrea::MetricsShipper.instance.start!
|
|
12
|
+
NurseAndrea::MemorySampler.start
|
|
12
13
|
Rails.logger.info("[NurseAndrea] Logger interceptor installed " \
|
|
13
14
|
"(host: #{NurseAndrea.config.host}, " \
|
|
14
15
|
"service: #{NurseAndrea.config.service_name || 'auto'})")
|
|
@@ -29,12 +30,37 @@ module NurseAndrea
|
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Component discovery + instrumentation hooks
|
|
34
|
+
initializer "nurse_andrea.instrumentation", after: :load_config_initializers do
|
|
35
|
+
next unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
36
|
+
|
|
37
|
+
# Platform detection
|
|
38
|
+
if NurseAndrea.config.platform_detection
|
|
39
|
+
ctx = NurseAndrea.platform_context
|
|
40
|
+
NurseAndrea.debug("[NurseAndrea] Platform: #{ctx[:platform]}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Managed service discovery (DATABASE_URL, REDIS_URL, etc.)
|
|
44
|
+
if NurseAndrea.config.service_discovery
|
|
45
|
+
discoveries = NurseAndrea::ManagedServiceScanner.scan
|
|
46
|
+
NurseAndrea.component_discoveries.concat(discoveries)
|
|
47
|
+
NurseAndrea.debug("[NurseAndrea] Discovered #{discoveries.size} managed services")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Hook subscriptions (sql.active_record, cache_*, perform.active_job, etc.)
|
|
51
|
+
if NurseAndrea.config.hooks_enabled
|
|
52
|
+
NurseAndrea.instrumentation_subscriber.subscribe_all
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
config.after_initialize do
|
|
33
57
|
next unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
34
58
|
NurseAndrea::Backfill.run_async!
|
|
59
|
+
NurseAndrea::QuerySubscriber.subscribe!
|
|
35
60
|
end
|
|
36
61
|
|
|
37
62
|
at_exit do
|
|
63
|
+
NurseAndrea::MemorySampler.stop rescue nil
|
|
38
64
|
NurseAndrea::LogShipper.instance.flush! rescue nil
|
|
39
65
|
NurseAndrea::MetricsShipper.instance.flush! rescue nil
|
|
40
66
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# PRIVACY: This file enforces the data privacy policy. Only allowlisted
|
|
2
|
+
# fields pass through. See DATA_PRIVACY_POLICY.rb for the full policy.
|
|
3
|
+
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module NurseAndrea
|
|
7
|
+
module Sanitizer
|
|
8
|
+
DISCOVERY_ALLOWLIST = %i[type tech provider source variable_name].freeze
|
|
9
|
+
|
|
10
|
+
def self.sanitize_discovery(raw)
|
|
11
|
+
raw.slice(*DISCOVERY_ALLOWLIST)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.extract_tech(url)
|
|
15
|
+
scheme = URI.parse(url).scheme rescue nil
|
|
16
|
+
case scheme
|
|
17
|
+
when "postgres", "postgresql" then "postgresql"
|
|
18
|
+
when "mysql", "mysql2" then "mysql"
|
|
19
|
+
when "redis", "rediss" then "redis"
|
|
20
|
+
when "amqp", "amqps" then "rabbitmq"
|
|
21
|
+
when "mongodb", "mongodb+srv" then "mongodb"
|
|
22
|
+
else "unknown"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.extract_provider(url)
|
|
27
|
+
host = URI.parse(url).host rescue nil
|
|
28
|
+
return "unknown" unless host
|
|
29
|
+
|
|
30
|
+
case host
|
|
31
|
+
when /\.railway\.internal$/ then "railway"
|
|
32
|
+
when /\.render\.com$/ then "render"
|
|
33
|
+
when /\.fly\.dev$/ then "fly"
|
|
34
|
+
when /\.neon\.tech$/ then "neon"
|
|
35
|
+
when /\.supabase\.co$/ then "supabase"
|
|
36
|
+
when /\.upstash\.io$/ then "upstash"
|
|
37
|
+
when /\.mongodb\.net$/ then "mongodb_atlas"
|
|
38
|
+
when /\.herokuapp\.com$/ then "heroku"
|
|
39
|
+
when /\.elephantsql\.com$/ then "elephantsql"
|
|
40
|
+
when /\.aws\.clickhouse\.cloud$/ then "clickhouse_cloud"
|
|
41
|
+
else "self_hosted"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Suppresses discovery emission when the SDK is loaded inside
|
|
2
|
+
# NurseAndrea itself. Both the InstrumentationSubscriber (hook-based)
|
|
3
|
+
# and the ManagedServiceScanner (env-based) must consult this filter
|
|
4
|
+
# before adding to NurseAndrea.component_discoveries — otherwise the
|
|
5
|
+
# platform's own infrastructure shows up as proposed components on
|
|
6
|
+
# every workspace dashboard.
|
|
7
|
+
|
|
8
|
+
module NurseAndrea
|
|
9
|
+
module SelfFilter
|
|
10
|
+
SELF_INDICATORS = %w[nurseandrea nurse-andrea nurse_andrea].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def platform_self?
|
|
14
|
+
return @platform_self if defined?(@platform_self)
|
|
15
|
+
@platform_self = compute_platform_self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reset!
|
|
19
|
+
remove_instance_variable(:@platform_self) if defined?(@platform_self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def host_matches?(*candidates)
|
|
23
|
+
candidates.compact.map(&:to_s).map(&:downcase).any? do |s|
|
|
24
|
+
SELF_INDICATORS.any? { |i| s.include?(i) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def compute_platform_self
|
|
31
|
+
return false unless defined?(Rails) && Rails.application
|
|
32
|
+
app_name = Rails.application.class.module_parent_name.to_s.downcase
|
|
33
|
+
SELF_INDICATORS.any? { |i| app_name.include?(i) }
|
|
34
|
+
rescue
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/nurse_andrea/version.rb
CHANGED
data/lib/nurse_andrea.rb
CHANGED
|
@@ -6,6 +6,17 @@ require "nurse_andrea/log_shipper"
|
|
|
6
6
|
require "nurse_andrea/metrics_middleware"
|
|
7
7
|
require "nurse_andrea/metrics_shipper"
|
|
8
8
|
require "nurse_andrea/backfill"
|
|
9
|
+
require "nurse_andrea/job_instrumentation"
|
|
10
|
+
require "nurse_andrea/queue_depth_reporter"
|
|
11
|
+
require "nurse_andrea/query_subscriber"
|
|
12
|
+
require "nurse_andrea/sanitizer"
|
|
13
|
+
require "nurse_andrea/platform_detector"
|
|
14
|
+
require "nurse_andrea/managed_service_scanner"
|
|
15
|
+
require "nurse_andrea/component_telemetry"
|
|
16
|
+
require "nurse_andrea/instrumentation_subscriber"
|
|
17
|
+
require "nurse_andrea/memory_sampler"
|
|
18
|
+
require "nurse_andrea/deploy"
|
|
19
|
+
require "nurse_andrea/self_filter"
|
|
9
20
|
|
|
10
21
|
require "nurse_andrea/railtie" if defined?(Rails::Railtie)
|
|
11
22
|
require "nurse_andrea/engine" if defined?(Rails::Engine)
|
|
@@ -24,6 +35,26 @@ module NurseAndrea
|
|
|
24
35
|
|
|
25
36
|
def reset_config!
|
|
26
37
|
@config = nil
|
|
38
|
+
@instrumentation_subscriber = nil
|
|
39
|
+
@component_discoveries = nil
|
|
40
|
+
@platform_context = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def instrumentation_subscriber
|
|
44
|
+
@instrumentation_subscriber ||= InstrumentationSubscriber.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def component_discoveries
|
|
48
|
+
@component_discoveries ||= []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def platform_context
|
|
52
|
+
@platform_context ||= PlatformDetector.context
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def debug(message)
|
|
56
|
+
return unless config.debug
|
|
57
|
+
$stderr.puts(message)
|
|
27
58
|
end
|
|
28
59
|
end
|
|
29
60
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nurse_andrea
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ago AI LLC
|
|
@@ -24,15 +24,27 @@ files:
|
|
|
24
24
|
- lib/generators/nurse_andrea/install/install_generator.rb
|
|
25
25
|
- lib/generators/nurse_andrea/install/templates/nurse_andrea.rb.tt
|
|
26
26
|
- lib/nurse_andrea.rb
|
|
27
|
+
- lib/nurse_andrea/DATA_PRIVACY_POLICY.rb
|
|
27
28
|
- lib/nurse_andrea/backfill.rb
|
|
29
|
+
- lib/nurse_andrea/component_telemetry.rb
|
|
28
30
|
- lib/nurse_andrea/configuration.rb
|
|
31
|
+
- lib/nurse_andrea/deploy.rb
|
|
29
32
|
- lib/nurse_andrea/engine.rb
|
|
30
33
|
- lib/nurse_andrea/http_client.rb
|
|
34
|
+
- lib/nurse_andrea/instrumentation_subscriber.rb
|
|
35
|
+
- lib/nurse_andrea/job_instrumentation.rb
|
|
31
36
|
- lib/nurse_andrea/log_interceptor.rb
|
|
32
37
|
- lib/nurse_andrea/log_shipper.rb
|
|
38
|
+
- lib/nurse_andrea/managed_service_scanner.rb
|
|
39
|
+
- lib/nurse_andrea/memory_sampler.rb
|
|
33
40
|
- lib/nurse_andrea/metrics_middleware.rb
|
|
34
41
|
- lib/nurse_andrea/metrics_shipper.rb
|
|
42
|
+
- lib/nurse_andrea/platform_detector.rb
|
|
43
|
+
- lib/nurse_andrea/query_subscriber.rb
|
|
44
|
+
- lib/nurse_andrea/queue_depth_reporter.rb
|
|
35
45
|
- lib/nurse_andrea/railtie.rb
|
|
46
|
+
- lib/nurse_andrea/sanitizer.rb
|
|
47
|
+
- lib/nurse_andrea/self_filter.rb
|
|
36
48
|
- lib/nurse_andrea/version.rb
|
|
37
49
|
- nurse_andrea.gemspec
|
|
38
50
|
homepage: https://nurseandrea.io
|