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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fff23d11632cc6f39d5b9348a63a7bf7d8b22b26bdce48272c3e11275e466e35
4
- data.tar.gz: dd99bed6832c23297a66f430a8d1dfb61cec7038913eed1ecb0667e005f14317
3
+ metadata.gz: ae308e57dd4f7a8bf6182f34e49a8223cc695448e9a0ef0146b37396e6deea19
4
+ data.tar.gz: 7323b870d40f057ac56130b11061ca84bb9d9db9f97f61697c1234d392df296b
5
5
  SHA512:
6
- metadata.gz: a1942f85a10fe0e6abb371d758a26ded283c3b07af1785e5f8f9cef526badb2fb9cea82080ebf54b8a54277aac46bc59bdc8f5134013341aa538e01ef03a4140
7
- data.tar.gz: 6276bb6c051cd9cabf6d6e85b03fafdc7257397ad576d4e8bb614ecb7364542fc62b6c7883ed5163a274a6d3ce315ea6f30769d16e0b799444e6e63300b44717
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 = true
20
- @debug = false
21
- @service_name = default_service_name
22
- @sdk_version = NurseAndrea::VERSION
23
- @sdk_language = "ruby"
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
- HttpClient.new.post(NurseAndrea.config.metrics_url, {
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module NurseAndrea
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.3"
3
3
  end
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.1.7
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