nurse_andrea 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fff23d11632cc6f39d5b9348a63a7bf7d8b22b26bdce48272c3e11275e466e35
4
- data.tar.gz: dd99bed6832c23297a66f430a8d1dfb61cec7038913eed1ecb0667e005f14317
3
+ metadata.gz: 43221daa0087ee909cc91fd846732ac477aeb2675d56ba02ecd7e3b301e808d7
4
+ data.tar.gz: 519daf63d6c80e238fe943d4a08f2bfba4ba365f9d5fa7492512e1df099a29f7
5
5
  SHA512:
6
- metadata.gz: a1942f85a10fe0e6abb371d758a26ded283c3b07af1785e5f8f9cef526badb2fb9cea82080ebf54b8a54277aac46bc59bdc8f5134013341aa538e01ef03a4140
7
- data.tar.gz: 6276bb6c051cd9cabf6d6e85b03fafdc7257397ad576d4e8bb614ecb7364542fc62b6c7883ed5163a274a6d3ce315ea6f30769d16e0b799444e6e63300b44717
6
+ metadata.gz: cd67cf5a3418cc74ec896ba1df43f62b3add0a2e266ae65aad02c2bb4f85d03c3cad4477352569fdaa568e5cae902829efe2e117fb7389cec3428a9054d429d1
7
+ data.tar.gz: 91dc589d97a93d19814cd0ad5b0eb27e9d3c097ef8bc7ecbd130187ea96f9d973577e7009abe2ff9295027fd57aae1bd2102e1422a7941295a50c628bd310d9e
@@ -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
@@ -0,0 +1,161 @@
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
+ adapter = event.payload[:connection]&.adapter_name rescue nil
52
+
53
+ # Skip platform's own ClickHouse queries — not the customer's infrastructure
54
+ return if adapter&.downcase&.include?("clickhouse")
55
+
56
+ tech = adapter_to_tech(adapter)
57
+ register_discovery("database", tech) if tech
58
+
59
+ table = extract_table(event.payload[:sql])
60
+ @telemetry.record_query(duration_ms: event.duration, table: table)
61
+ end
62
+
63
+ def on_transaction(event)
64
+ outcome = event.payload[:outcome]
65
+ @telemetry.record_transaction(outcome: outcome)
66
+ end
67
+
68
+ def on_cache_read(event)
69
+ tech = store_to_tech(event.payload[:store])
70
+
71
+ # Only register external cache stores as components
72
+ register_discovery("cache", tech) if tech
73
+
74
+ # Still record telemetry for all cache reads
75
+ @telemetry.record_cache_read(hit: event.payload[:hit])
76
+ end
77
+
78
+ def on_cache_write(_event)
79
+ @telemetry.record_cache_write
80
+ end
81
+
82
+ def on_cache_delete(_event)
83
+ @telemetry.record_cache_delete
84
+ end
85
+
86
+ def on_job_perform(event)
87
+ adapter = detect_queue_adapter
88
+ register_discovery("queue", adapter)
89
+ @telemetry.record_job_complete(
90
+ duration_ms: event.duration,
91
+ queue_name: event.payload[:job]&.queue_name
92
+ )
93
+ end
94
+
95
+ def on_job_enqueue(event)
96
+ @telemetry.record_job_enqueue(queue_name: event.payload[:job]&.queue_name)
97
+ end
98
+
99
+ def on_mailer(_event)
100
+ register_discovery("external", "email")
101
+ end
102
+
103
+ def register_discovery(type, tech)
104
+ return if tech.nil? || tech.empty? || tech == "unknown"
105
+
106
+ key = "#{type}:#{tech}"
107
+ return if @discovered_components.include?(key)
108
+
109
+ @discovered_components.add(key)
110
+ NurseAndrea.component_discoveries << Sanitizer.sanitize_discovery(
111
+ type: type, tech: tech, provider: "unknown",
112
+ source: "hook_subscription", variable_name: nil
113
+ )
114
+ end
115
+
116
+ def adapter_to_tech(adapter_name)
117
+ case adapter_name&.downcase
118
+ when "postgresql", "postgis" then "postgresql"
119
+ when "mysql2", "trilogy" then "mysql"
120
+ when "sqlite3" then "sqlite"
121
+ when "clickhouse" then nil
122
+ else adapter_name&.downcase.presence
123
+ end
124
+ end
125
+
126
+ def store_to_tech(store_class)
127
+ case store_class.to_s
128
+ when /Redis/i then "redis"
129
+ when /Memcache/i then "memcached"
130
+ when /Memory/i then nil # In-process, not infrastructure
131
+ when /File/i then nil # Local filesystem, not infrastructure
132
+ when /Null/i then nil # No-op store
133
+ else nil
134
+ end
135
+ end
136
+
137
+ def detect_queue_adapter
138
+ return "unknown" unless defined?(ActiveJob::Base)
139
+ adapter = ActiveJob::Base.queue_adapter.class.name rescue nil
140
+ case adapter
141
+ when /Sidekiq/i then "sidekiq"
142
+ when /SolidQueue/i then "solid_queue"
143
+ when /Resque/i then "resque"
144
+ when /DelayedJob/i then "delayed_job"
145
+ when /GoodJob/i then "good_job"
146
+ else "unknown"
147
+ end
148
+ end
149
+
150
+ def extract_table(sql)
151
+ return nil if sql.nil? || sql.empty?
152
+ if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
153
+ $1
154
+ elsif sql =~ /\bINTO\s+["`]?(\w+)["`]?/i
155
+ $1
156
+ elsif sql =~ /\bUPDATE\s+["`]?(\w+)["`]?/i
157
+ $1
158
+ end
159
+ end
160
+ end
161
+ 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,45 @@
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
+ discoveries = []
23
+
24
+ SCAN_MAP.each do |var_name, component_type|
25
+ url = ENV[var_name]
26
+ next if url.nil? || url.strip.empty?
27
+
28
+ tech = Sanitizer.extract_tech(url)
29
+ provider = Sanitizer.extract_provider(url)
30
+
31
+ raw = {
32
+ type: component_type,
33
+ tech: tech,
34
+ provider: provider,
35
+ source: "env_detection",
36
+ variable_name: var_name
37
+ }
38
+
39
+ discoveries << Sanitizer.sanitize_discovery(raw)
40
+ end
41
+
42
+ discoveries.uniq { |d| [d[:type], d[:tech], d[:provider]] }
43
+ end
44
+ end
45
+ 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
@@ -1,3 +1,3 @@
1
1
  module NurseAndrea
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/nurse_andrea.rb CHANGED
@@ -6,6 +6,15 @@ 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"
9
18
 
10
19
  require "nurse_andrea/railtie" if defined?(Rails::Railtie)
11
20
  require "nurse_andrea/engine" if defined?(Rails::Engine)
@@ -24,6 +33,26 @@ module NurseAndrea
24
33
 
25
34
  def reset_config!
26
35
  @config = nil
36
+ @instrumentation_subscriber = nil
37
+ @component_discoveries = nil
38
+ @platform_context = nil
39
+ end
40
+
41
+ def instrumentation_subscriber
42
+ @instrumentation_subscriber ||= InstrumentationSubscriber.new
43
+ end
44
+
45
+ def component_discoveries
46
+ @component_discoveries ||= []
47
+ end
48
+
49
+ def platform_context
50
+ @platform_context ||= PlatformDetector.context
51
+ end
52
+
53
+ def debug(message)
54
+ return unless config.debug
55
+ $stderr.puts(message)
27
56
  end
28
57
  end
29
58
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ago AI LLC
@@ -24,15 +24,25 @@ 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
29
31
  - lib/nurse_andrea/engine.rb
30
32
  - lib/nurse_andrea/http_client.rb
33
+ - lib/nurse_andrea/instrumentation_subscriber.rb
34
+ - lib/nurse_andrea/job_instrumentation.rb
31
35
  - lib/nurse_andrea/log_interceptor.rb
32
36
  - lib/nurse_andrea/log_shipper.rb
37
+ - lib/nurse_andrea/managed_service_scanner.rb
38
+ - lib/nurse_andrea/memory_sampler.rb
33
39
  - lib/nurse_andrea/metrics_middleware.rb
34
40
  - lib/nurse_andrea/metrics_shipper.rb
41
+ - lib/nurse_andrea/platform_detector.rb
42
+ - lib/nurse_andrea/query_subscriber.rb
43
+ - lib/nurse_andrea/queue_depth_reporter.rb
35
44
  - lib/nurse_andrea/railtie.rb
45
+ - lib/nurse_andrea/sanitizer.rb
36
46
  - lib/nurse_andrea/version.rb
37
47
  - nurse_andrea.gemspec
38
48
  homepage: https://nurseandrea.io