nurse_andrea 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43221daa0087ee909cc91fd846732ac477aeb2675d56ba02ecd7e3b301e808d7
4
- data.tar.gz: 519daf63d6c80e238fe943d4a08f2bfba4ba365f9d5fa7492512e1df099a29f7
3
+ metadata.gz: 7af261428fe8313fb24aa0c2a90ba94a14791040d1c83cd3bb52ee743f6a3e58
4
+ data.tar.gz: 0ef0cb18d33855bf8c510cc7da7ced9e0bfc7d67603add9bc739e1cb7b3809a0
5
5
  SHA512:
6
- metadata.gz: cd67cf5a3418cc74ec896ba1df43f62b3add0a2e266ae65aad02c2bb4f85d03c3cad4477352569fdaa568e5cae902829efe2e117fb7389cec3428a9054d429d1
7
- data.tar.gz: 91dc589d97a93d19814cd0ad5b0eb27e9d3c097ef8bc7ecbd130187ea96f9d973577e7009abe2ff9295027fd57aae1bd2102e1422a7941295a50c628bd310d9e
6
+ metadata.gz: 4e7b22b9c82fb8f952148d7b5c30ba0cee5714f2b596631f5f89ef6618887a6cd86f4556ec456811b68eae92be802a32eb9cc86f360d93c5f9bba68dfe3e5385
7
+ data.tar.gz: 5c28c6a373e0c22582006ddfe5a237a5b99e57efc2f37bb786985db5a724d07894443891846043f5e29b41debde88cf969372e477fc4f80575d1cd81b02a6025
@@ -6,7 +6,8 @@ module NurseAndrea
6
6
  :sdk_version, :sdk_language,
7
7
  :hooks_enabled, :hooks_database, :hooks_cache,
8
8
  :hooks_jobs, :hooks_mailer, :hook_interval_ms,
9
- :platform_detection, :service_discovery, :auto_connect
9
+ :platform_detection, :service_discovery, :auto_connect,
10
+ :disable_continuous_scan, :continuous_scan_interval
10
11
 
11
12
  LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
12
13
  DEFAULT_HOST = "https://nurseandrea.io"
@@ -33,6 +34,8 @@ module NurseAndrea
33
34
  @platform_detection = true
34
35
  @service_discovery = true
35
36
  @auto_connect = false
37
+ @disable_continuous_scan = false
38
+ @continuous_scan_interval = 5 * 60 # seconds
36
39
  end
37
40
 
38
41
  alias_method :token, :api_key
@@ -43,6 +46,7 @@ module NurseAndrea
43
46
  def metrics_url = "#{normalised_host}/api/v1/metrics"
44
47
  def traces_url = "#{normalised_host}/api/v1/traces"
45
48
  def handshake_url = "#{normalised_host}/api/v1/handshake"
49
+ def deploy_url = "#{normalised_host}/api/v1/deploy"
46
50
 
47
51
  def enabled?
48
52
  @enabled
@@ -0,0 +1,92 @@
1
+ # PRIVACY: Same guarantees as ManagedServiceScanner — only derived
2
+ # metadata is transmitted, never raw env values. See
3
+ # DATA_PRIVACY_POLICY.rb for the full policy.
4
+ #
5
+ # Periodically re-runs the env-based discovery scan so dependencies
6
+ # added after boot (new env vars, services attached to running
7
+ # instances, configuration reloads) eventually surface as
8
+ # discoveries on the workspace dashboard.
9
+ #
10
+ # Properties guaranteed by the contract below:
11
+ # * Non-blocking — runs on a dedicated background thread, never on
12
+ # the request path.
13
+ # * Bounded — only one thread; calling start! twice is a no-op.
14
+ # * Fail-safe — any error inside the scan is swallowed; the host
15
+ # application never crashes from a discovery error.
16
+ # * Self-aware — short-circuits when SelfFilter.platform_self?
17
+ # because every URL would be NurseAndrea's own infra.
18
+ # * Stoppable — explicit stop! signals the thread to exit at the
19
+ # next sleep boundary; configuration.disable_continuous_scan
20
+ # prevents start! from creating the thread at all.
21
+ #
22
+ # Fork-safety note: the thread does NOT survive Process._fork (Puma
23
+ # clustered mode, Sidekiq workers). If the host forks after boot, the
24
+ # parent's scanner thread is gone and a new one is not auto-started.
25
+ # Address in a follow-up if it shows up in practice — for one-off
26
+ # webservers and single-process deployments the current design is
27
+ # sufficient.
28
+
29
+ module NurseAndrea
30
+ class ContinuousScanner
31
+ @thread = nil
32
+ @stop = false
33
+ @mutex = Mutex.new
34
+
35
+ class << self
36
+ attr_reader :thread
37
+
38
+ def start!
39
+ @mutex.synchronize do
40
+ return if @thread&.alive?
41
+ return if NurseAndrea.config.disable_continuous_scan
42
+
43
+ @stop = false
44
+ @thread = Thread.new { run_loop }
45
+ @thread.name = "nurse_andrea_continuous_scanner"
46
+ end
47
+ end
48
+
49
+ def stop!
50
+ @mutex.synchronize do
51
+ @stop = true
52
+ end
53
+ @thread&.wakeup rescue nil
54
+ @thread&.join(2)
55
+ @thread = nil
56
+ end
57
+
58
+ def running?
59
+ @thread&.alive? == true
60
+ end
61
+
62
+ # Public so specs can call it without spinning the loop.
63
+ def rescan_safely
64
+ return if NurseAndrea::SelfFilter.platform_self?
65
+
66
+ discoveries = NurseAndrea::ManagedServiceScanner.scan
67
+ return if discoveries.empty?
68
+
69
+ # Only push new items — ManagedServiceScanner already dedups
70
+ # by (type, tech, provider) within a single call, so we just
71
+ # need to avoid resubmitting items already queued for the
72
+ # current flush.
73
+ existing_keys = NurseAndrea.component_discoveries.map { |d| [ d[:type], d[:tech], d[:provider] ] }
74
+ discoveries.reject! { |d| existing_keys.include?([ d[:type], d[:tech], d[:provider] ]) }
75
+ NurseAndrea.component_discoveries.concat(discoveries) if discoveries.any?
76
+ rescue => e
77
+ NurseAndrea.debug("[ContinuousScanner] error: #{e.class}: #{e.message}")
78
+ end
79
+
80
+ private
81
+
82
+ def run_loop
83
+ interval = NurseAndrea.config.continuous_scan_interval
84
+ until @stop
85
+ sleep interval
86
+ break if @stop
87
+ rescan_safely
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -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
@@ -14,7 +14,7 @@ module NurseAndrea
14
14
  "cache_delete.active_support" => :on_cache_delete,
15
15
  "perform.active_job" => :on_job_perform,
16
16
  "enqueue.active_job" => :on_job_enqueue,
17
- "deliver.action_mailer" => :on_mailer,
17
+ "deliver.action_mailer" => :on_mailer
18
18
  }.freeze
19
19
 
20
20
  attr_reader :telemetry, :discovered_components
@@ -48,13 +48,14 @@ module NurseAndrea
48
48
  return if event.payload[:name] == "SCHEMA"
49
49
  return if event.payload[:name]&.start_with?("EXPLAIN")
50
50
 
51
- adapter = event.payload[:connection]&.adapter_name rescue nil
51
+ conn = event.payload[:connection]
52
+ adapter = conn&.adapter_name rescue nil
52
53
 
53
54
  # Skip platform's own ClickHouse queries — not the customer's infrastructure
54
55
  return if adapter&.downcase&.include?("clickhouse")
55
56
 
56
57
  tech = adapter_to_tech(adapter)
57
- register_discovery("database", tech) if tech
58
+ register_discovery("database", tech, connection: conn) if tech
58
59
 
59
60
  table = extract_table(event.payload[:sql])
60
61
  @telemetry.record_query(duration_ms: event.duration, table: table)
@@ -100,8 +101,9 @@ module NurseAndrea
100
101
  register_discovery("external", "email")
101
102
  end
102
103
 
103
- def register_discovery(type, tech)
104
+ def register_discovery(type, tech, connection: nil)
104
105
  return if tech.nil? || tech.empty? || tech == "unknown"
106
+ return if self_referential?(connection)
105
107
 
106
108
  key = "#{type}:#{tech}"
107
109
  return if @discovered_components.include?(key)
@@ -113,6 +115,20 @@ module NurseAndrea
113
115
  )
114
116
  end
115
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
+
116
132
  def adapter_to_tech(adapter_name)
117
133
  case adapter_name&.downcase
118
134
  when "postgresql", "postgis" then "postgresql"
@@ -15,15 +15,21 @@ module NurseAndrea
15
15
  "MONGODB_URI" => "database",
16
16
  "MONGO_URL" => "database",
17
17
  "ELASTICSEARCH_URL" => "search",
18
- "KAFKA_BROKERS" => "queue",
18
+ "KAFKA_BROKERS" => "queue"
19
19
  }.freeze
20
20
 
21
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
+
22
27
  discoveries = []
23
28
 
24
29
  SCAN_MAP.each do |var_name, component_type|
25
30
  url = ENV[var_name]
26
31
  next if url.nil? || url.strip.empty?
32
+ next if NurseAndrea::SelfFilter.host_matches?(url)
27
33
 
28
34
  tech = Sanitizer.extract_tech(url)
29
35
  provider = Sanitizer.extract_provider(url)
@@ -39,7 +45,7 @@ module NurseAndrea
39
45
  discoveries << Sanitizer.sanitize_discovery(raw)
40
46
  end
41
47
 
42
- discoveries.uniq { |d| [d[:type], d[:tech], d[:provider]] }
48
+ discoveries.uniq { |d| [ d[:type], d[:tech], d[:provider] ] }
43
49
  end
44
50
  end
45
51
  end
@@ -10,7 +10,7 @@ module NurseAndrea
10
10
  fly: -> { ENV.key?("FLY_APP_NAME") },
11
11
  heroku: -> { ENV.key?("DYNO") },
12
12
  digitalocean: -> { ENV.key?("DIGITALOCEAN_APP_PLATFORM_COMPONENT_NAME") },
13
- vercel: -> { ENV.key?("VERCEL") },
13
+ vercel: -> { ENV.key?("VERCEL") }
14
14
  }.freeze
15
15
 
16
16
  def self.detect
@@ -45,6 +45,11 @@ module NurseAndrea
45
45
  discoveries = NurseAndrea::ManagedServiceScanner.scan
46
46
  NurseAndrea.component_discoveries.concat(discoveries)
47
47
  NurseAndrea.debug("[NurseAndrea] Discovered #{discoveries.size} managed services")
48
+
49
+ # Periodic rescan — picks up dependencies added after boot
50
+ # (env reloads, attached services). Respects
51
+ # disable_continuous_scan and SelfFilter short-circuit.
52
+ NurseAndrea::ContinuousScanner.start!
48
53
  end
49
54
 
50
55
  # Hook subscriptions (sql.active_record, cache_*, perform.active_job, etc.)
@@ -60,6 +65,7 @@ module NurseAndrea
60
65
  end
61
66
 
62
67
  at_exit do
68
+ NurseAndrea::ContinuousScanner.stop! rescue nil
63
69
  NurseAndrea::MemorySampler.stop rescue nil
64
70
  NurseAndrea::LogShipper.instance.flush! rescue nil
65
71
  NurseAndrea::MetricsShipper.instance.flush! rescue nil
@@ -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
@@ -1,3 +1,3 @@
1
1
  module NurseAndrea
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.4"
3
3
  end
data/lib/nurse_andrea.rb CHANGED
@@ -15,6 +15,9 @@ require "nurse_andrea/managed_service_scanner"
15
15
  require "nurse_andrea/component_telemetry"
16
16
  require "nurse_andrea/instrumentation_subscriber"
17
17
  require "nurse_andrea/memory_sampler"
18
+ require "nurse_andrea/deploy"
19
+ require "nurse_andrea/self_filter"
20
+ require "nurse_andrea/continuous_scanner"
18
21
 
19
22
  require "nurse_andrea/railtie" if defined?(Rails::Railtie)
20
23
  require "nurse_andrea/engine" if defined?(Rails::Engine)
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.2.1
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ago AI LLC
@@ -28,6 +28,8 @@ files:
28
28
  - lib/nurse_andrea/backfill.rb
29
29
  - lib/nurse_andrea/component_telemetry.rb
30
30
  - lib/nurse_andrea/configuration.rb
31
+ - lib/nurse_andrea/continuous_scanner.rb
32
+ - lib/nurse_andrea/deploy.rb
31
33
  - lib/nurse_andrea/engine.rb
32
34
  - lib/nurse_andrea/http_client.rb
33
35
  - lib/nurse_andrea/instrumentation_subscriber.rb
@@ -43,6 +45,7 @@ files:
43
45
  - lib/nurse_andrea/queue_depth_reporter.rb
44
46
  - lib/nurse_andrea/railtie.rb
45
47
  - lib/nurse_andrea/sanitizer.rb
48
+ - lib/nurse_andrea/self_filter.rb
46
49
  - lib/nurse_andrea/version.rb
47
50
  - nurse_andrea.gemspec
48
51
  homepage: https://nurseandrea.io