nurse_andrea 0.2.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6b94793dda326f7f9733ff52b0c6186ce4d8d5ec638f939ef7f18db382890e0
4
- data.tar.gz: f49b5d9bdaffc62d0752988213dfe37b8868f1ca792ebce9fc7819ead551e4a1
3
+ metadata.gz: 2b6961a1d90b45d5fa6976a12a1bcf05d6fde5716a376009be905b77c29fe585
4
+ data.tar.gz: f3c038127559cd6f999c58d24cb1baf4b3d18baf18cd2bb0996b8e5e3be0accc
5
5
  SHA512:
6
- metadata.gz: c4d4f410da8ca576d2465b1e54df730ad1bfb7305537b5045e2571088c131ace035691c0dbe37a31dd9a807cfd32a770f751cbb5a85b736b45a64aa88a48f9d9
7
- data.tar.gz: 8fbefdc77087c52bf2e9bbb4d7ae60a16a46bfc8dac69865180a926a76288f22ab1bba5934cfc548b762833b8d1bcaa7588a5326156d703415e18ac0ab53c0db
6
+ metadata.gz: 669545d0cd7db5e37ec834a72759dbf487a57eee0b3cde9c6be253bf1a129cedfba8050506ece92f794713742ea68c9aec5de9d87890931b5a55fb7640a0a59d
7
+ data.tar.gz: ed87e34c7496ba60bd7d3634252aa8f67dd628b5d0a8b3902447f81bed74c2b1bd262f1cb5a9ea4d0f3e505c22f499140d4dec9a712d25801564dd06aea41bb7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,150 @@
1
+ ## [1.4.0] - 2026-05-19
2
+
3
+ ### Fixed — Self-filter R-14 compliance (False-Discoveries Patch)
4
+ - `SelfFilter.host_matches?` previously matched against hard-coded
5
+ `SELF_INDICATORS = %w[nurseandrea nurse-andrea nurse_andrea]`. Customer
6
+ connections whose host / URL / database name happened to contain any
7
+ of those substrings were silently dropped from discovery emission —
8
+ a false-negative on customer telemetry that had been live since the
9
+ original fix shipped (vintage `da4b3e4`).
10
+ - `host_matches?` now derives its match indicator from
11
+ `NurseAndrea.config.host` (strips scheme/port/path). Per environment:
12
+ prod → `nurseandrea.io`; staging → `staging.nurseandrea.io`;
13
+ development → `localhost`. Customer databases literally named
14
+ `nurse_andrea_*` are no longer affected.
15
+ - `platform_self?` is unchanged. It matches the SDK's own Rails app
16
+ name (process-level self-identification), not user-supplied URLs,
17
+ so the R-14 anti-pattern does not apply there. File header
18
+ documents the distinction.
19
+
20
+ ### Cross-runtime parity (R-15(b))
21
+ - All 4 SDKs have functional parity on the published feature surface
22
+ as of v1.3. See `docs/sdk/v1.4.0-feature-parity-audit.md` in the
23
+ monorepo for the full matrix and the methodology behind the claim.
24
+ Job/queue execution instrumentation outside Ruby ActiveJob is a
25
+ known pre-rule gap and is scheduled for a separate post-v1.4.0
26
+ sprint. HTTP outbound capture of customer code is **not** claimed
27
+ by v1.4.0 — no SDK currently implements it.
28
+
29
+ ## [1.3.0] - 2026-05-14
30
+
31
+ ### Added — Rack-compatible core (GAP-09, Sprint D D1)
32
+ - The gem now loads cleanly in non-Rails Ruby processes (Sinatra,
33
+ plain Rack, background workers, CLI tools). Previously
34
+ `require "nurse_andrea"` raised `LoadError` outside a Rails
35
+ context because `job_instrumentation.rb`'s top-level
36
+ `require "active_support/concern"` failed without ActiveSupport
37
+ installed.
38
+ - Public surface available without Rails:
39
+ `NurseAndrea.configure`, `NurseAndrea.config.valid?`,
40
+ `NurseAndrea::LogShipper.instance`,
41
+ `NurseAndrea::MetricsShipper.instance`,
42
+ `NurseAndrea::MetricsMiddleware` (Rack middleware),
43
+ `NurseAndrea.deploy(...)`. See the README's "Non-Rails usage"
44
+ section for the minimal Sinatra / Rack setup.
45
+ - New spec at `spec/nurse_andrea/rack_compat_spec.rb` spawns a
46
+ subprocess with the Bundler env stripped and asserts the gem
47
+ loads, configures, and exposes the shippers without pulling in
48
+ Rails or ActiveSupport. Standing rule #12: load behavior is now
49
+ asserted by a spec, not by manual verification.
50
+
51
+ ### Internal
52
+ - `lib/nurse_andrea.rb` now separates Rack-compatible requires
53
+ (unconditional) from Rails-only requires (guarded by
54
+ `defined?(ActiveSupport::Concern)` / `defined?(Rails::Railtie)` /
55
+ `defined?(Rails::Engine)`). No changes to the internal
56
+ implementation of any individual file — only their require's
57
+ load-time position.
58
+
59
+ ## [1.2.0] - 2026-05-14
60
+
61
+ ### Added
62
+ - **`X-NurseAndrea-Timestamp` header on every outbound POST.** Set
63
+ to `Time.now.to_i.to_s` (unix-seconds integer) on logs, metrics,
64
+ and deploy. Server-side window validation (±5 minutes) lands in
65
+ the matching NurseAndrea Rails release; requests outside the
66
+ window are rejected with HTTP 401
67
+ `error: "timestamp_out_of_window"`.
68
+ This is GAP-07 Phase 2 of the replay-mitigation work — see
69
+ `SECURITY.md` and `docs/sdk/payload-format.md` §2.1 for the
70
+ threat-model rationale and Phase 3 (HMAC) roadmap.
71
+ - Sprint B parity test extended to assert the timestamp header is
72
+ present and within drift on logs / metrics / deploy.
73
+
74
+ ### Notes
75
+ - **Backward compatibility.** Servers running the matching Rails
76
+ release accept requests **without** the timestamp header (older
77
+ SDKs), so upgrading the SDK without coordinating the server
78
+ upgrade is safe. The accept-when-absent behavior goes away in
79
+ Phase 3.
80
+
81
+ ## [1.1.0] - 2026-05-14
82
+
83
+ ### Fixed (cross-runtime parity — wire spec at docs/sdk/payload-format.md)
84
+ - Status endpoint regression: `GET /nurse_andrea/status` returned
85
+ 500 since the 1.0 release because the controller's `masked_token`
86
+ helper referenced the pre-1.0 `config.api_key` field, which raises
87
+ `MigrationError` in 1.0. The endpoint now reads `config.org_token`.
88
+ Caught by the new Sprint A host-app CI fixture; in-the-wild
89
+ installs running 1.0 should upgrade to restore the health check.
90
+
91
+ ### Notes
92
+ - No behavior changes in this release for Ruby host apps beyond the
93
+ status-endpoint fix above. Ruby was already aligned with the
94
+ cross-runtime payload spec (Sprint B audit confirmed Ruby
95
+ emits the canonical log and metric field names —
96
+ `occurred_at` / `source` / `payload` and `occurred_at` —
97
+ that the spec adopts).
98
+ - Ruby-only optional emissions documented in
99
+ docs/sdk/payload-format.md and excluded from the parity contract:
100
+ the `User-Agent: nurse_andrea-ruby/<version>` header, per-log
101
+ `batch_id` for request tracing, top-level `platform` on metrics
102
+ (when platform detection is enabled), `component_discoveries`
103
+ (when service discovery is enabled), `component_metrics` (from
104
+ the InstrumentationSubscriber).
105
+
106
+ ### Added
107
+ - `NurseAndrea::BootDiagnostics` — Sprint A D6 module that provides
108
+ per-cause warn messages from the Railtie when configuration is
109
+ incomplete. Replaces the generic "Configuration incomplete at
110
+ logger wrap time" warn with messages naming the specific failure
111
+ mode and the env var or setter to update.
112
+
113
+ ## [1.0.0] - 2026-05-06
114
+
115
+ ### Breaking
116
+ - Replaced `api_key` / `token` / `ingest_token` with three required
117
+ fields: `org_token`, `workspace_slug`, `environment`. Old field
118
+ setters and getters now raise `NurseAndrea::MigrationError` at boot
119
+ with a link to the migration guide. There is no compatibility shim.
120
+ - New required request headers: `Authorization: Bearer <org_token>`,
121
+ `X-NurseAndrea-Workspace: <slug>`, `X-NurseAndrea-Environment: <env>`.
122
+ Single-token-per-workspace auth is gone.
123
+ - `environment` must be one of `production`, `staging`, `development`.
124
+ Anything else raises a `ConfigurationError` at `validate!` time.
125
+ `RAILS_ENV=test` auto-detects to `production` with a one-time warning.
126
+ - `workspace_slug` is validated locally (lowercase a-z, 0-9, hyphens;
127
+ must start with a letter; 1-64 chars). Reserved-word enforcement
128
+ remains server-authoritative.
129
+
130
+ ### Added
131
+ - `NurseAndrea::SlugValidator` — local format validation.
132
+ - `NurseAndrea::EnvironmentDetector` — auto-detection from `RAILS_ENV`
133
+ / `RACK_ENV` with a one-time stderr warning on unsupported values.
134
+ - `NurseAndrea::MigrationError` (descends from `ConfigurationError`).
135
+ - Structured rejection handling: after 5 consecutive `401`/`403`/`422`/
136
+ `429` responses with the same error code, the SDK prints one stderr
137
+ warning per process lifecycle with actionable guidance keyed off the
138
+ server's `error` field (e.g. `invalid_org_token`, `workspace_rejected`,
139
+ `auto_create_disabled`, `similar_slug_exists`).
140
+ - `X-NurseAndrea-SDK: ruby/<version>` identity header on every request.
141
+
142
+ ### Migration
143
+ - See https://docs.nurseandrea.io/sdk/migration. Short version: replace
144
+ the single `c.token` line with `c.org_token`, `c.workspace_slug`,
145
+ `c.environment`. Pull `org_token` from the org settings page (was
146
+ `account.token`); pick a slug for each app/service.
147
+
1
148
  ## [0.1.7] - 2026-04-06
2
149
 
3
150
  ### Added
data/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # NurseAndrea Ruby SDK
2
2
 
3
- The official Ruby gem for [NurseAndrea](https://nurseandrea.com) — observability for Rails startups.
3
+ The official Ruby gem for [NurseAndrea](https://nurseandrea.io) —
4
+ observability for Rails startups and any plain-Ruby service. Version
5
+ `1.3.0`.
6
+
7
+ As of 1.3.0 the gem loads cleanly in non-Rails Ruby processes
8
+ (Sinatra, plain Rack, background workers, CLI tools). See
9
+ [Non-Rails usage](#non-rails-usage) below.
4
10
 
5
11
  ## Installation
6
12
 
@@ -17,30 +23,107 @@ bundle install
17
23
  rails generate nurse_andrea:install
18
24
  ```
19
25
 
20
- Set your API key:
26
+ Set the required environment variable:
21
27
 
22
28
  ```bash
23
- export NURSE_ANDREA_API_KEY="your_token_from_dashboard"
29
+ export NURSE_ANDREA_ORG_TOKEN="org_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
24
30
  ```
25
31
 
26
32
  ## What it does
27
33
 
28
- - **Log shipping** — captures all `Rails.logger` calls and ships them to your NurseAndrea dashboard
29
- - **Request metrics** — measures every HTTP request (duration, status code, path) via Rack middleware
30
- - **Backfill** — ships the last 24h of your Rails log file on first startup
31
- - **Health endpoint** — mounts `/nurse_andrea/status` so the dashboard can verify your connection
34
+ - **Log shipping** — captures `Rails.logger` calls and ships them to
35
+ the NurseAndrea dashboard.
36
+ - **Request metrics** — measures every HTTP request (duration,
37
+ status, path) via Rack middleware.
38
+ - **Backfill** — ships the last 24h of your Rails log file on first
39
+ startup.
40
+ - **Health endpoint** — mounts `/nurse_andrea/status` so the
41
+ dashboard can verify the integration.
32
42
 
33
43
  ## Configuration
34
44
 
45
+ The `rails generate nurse_andrea:install` generator drops
46
+ `config/initializers/nurse_andrea.rb`:
47
+
35
48
  ```ruby
36
- NurseAndrea.configure do |config|
37
- config.api_key = ENV["NURSE_ANDREA_API_KEY"]
38
- config.log_level = :warn
39
- config.backfill_hours = 48
40
- config.enabled = !Rails.env.test?
49
+ NurseAndrea.configure do |c|
50
+ c.org_token = ENV["NURSE_ANDREA_ORG_TOKEN"]
51
+ c.workspace_slug = "your-app"
52
+ c.environment = ENV.fetch("RAILS_ENV", "production")
53
+ c.host = ENV.fetch("NURSE_ANDREA_HOST", "https://nurseandrea.io")
54
+ c.enabled = !Rails.env.test?
55
+ c.log_level = :warn
41
56
  end
42
57
  ```
43
58
 
59
+ All three of `org_token`, `workspace_slug`, and `environment` are
60
+ required. Missing any of them silently disables the SDK and emits a
61
+ `stderr` warn — see [SECURITY.md](../../SECURITY.md) for the
62
+ misconfiguration contract.
63
+
64
+ ## Non-Rails usage
65
+
66
+ The gem's core ships logs and metrics through plain Ruby — Rails is
67
+ only needed for the Rails-specific glue listed below. Sinatra,
68
+ plain-Rack, background workers, and non-web services can `require`
69
+ the gem directly:
70
+
71
+ ```ruby
72
+ require "nurse_andrea"
73
+
74
+ NurseAndrea.configure do |c|
75
+ c.org_token = ENV["NURSE_ANDREA_ORG_TOKEN"]
76
+ c.workspace_slug = "my-service"
77
+ c.environment = ENV.fetch("RACK_ENV", "production")
78
+ end
79
+
80
+ # Ship logs directly:
81
+ NurseAndrea::LogShipper.instance.enqueue(
82
+ level: "info",
83
+ message: "hello from sinatra",
84
+ timestamp: Time.now.utc.iso8601(3)
85
+ )
86
+
87
+ # Or wrap the HTTP-metrics middleware in a Rack app:
88
+ use NurseAndrea::MetricsMiddleware
89
+ ```
90
+
91
+ ### What's available without Rails
92
+
93
+ - `NurseAndrea.configure`, `NurseAndrea.config`,
94
+ `NurseAndrea.config.valid?`
95
+ - `NurseAndrea::LogShipper.instance` — direct log ingest API
96
+ - `NurseAndrea::MetricsShipper.instance` — direct metric ingest API
97
+ - `NurseAndrea::MetricsMiddleware` — Rack middleware for HTTP
98
+ request duration / status / path
99
+ - `NurseAndrea.deploy(...)` — deploy markers
100
+ - Platform / managed-service / continuous discovery scanners
101
+
102
+ ### What requires Rails
103
+
104
+ These features auto-wire when Rails is present and are inactive in
105
+ non-Rails processes. If you need any of them, install in a Rails
106
+ app instead:
107
+
108
+ - `NurseAndrea::Engine` mount at `/nurse_andrea/status` — the
109
+ dashboard's health-check endpoint
110
+ - `NurseAndrea::Railtie` boot hooks — automatic
111
+ `Rails.logger` wrapping, middleware installation,
112
+ initializer-time validation, per-cause boot warnings
113
+ - `rails generate nurse_andrea:install` — drops the initializer
114
+ - `ActiveSupport::Notifications`-based instrumentation
115
+ (`sql.active_record`, `cache_*`, `perform.active_job`,
116
+ `deliver.action_mailer`)
117
+ - `NurseAndrea::JobInstrumentation` — `around_perform` hook for
118
+ ActiveJob jobs
119
+ - Automatic backfill of the Rails log file on first boot
120
+
121
+ ## Migration from 0.x
122
+
123
+ `api_key`, `token`, and `ingest_token` are no longer supported.
124
+ Setting any of them raises `NurseAndrea::MigrationError` at boot.
125
+ See [`docs/sdk/migration.md`](../../docs/sdk/migration.md).
126
+
44
127
  ## Version history
45
128
 
46
129
  See [CHANGELOG.md](CHANGELOG.md).
@@ -18,7 +18,11 @@ module NurseAndrea
18
18
  private
19
19
 
20
20
  def masked_token
21
- token = NurseAndrea.config.api_key.to_s
21
+ # SDK Sprint A D3 (GAP-03 surfaced this) — pre-1.0 referenced
22
+ # config.api_key, which now raises MigrationError. The 1.0
23
+ # field is org_token; the status controller was missed during
24
+ # the auth-contract rewrite. Host-app fixture smoke caught it.
25
+ token = NurseAndrea.config.org_token.to_s
22
26
  return "not_configured" if token.empty?
23
27
  "#{token[0..7]}..."
24
28
  end
@@ -1,16 +1,24 @@
1
1
  # NurseAndrea Observability SDK
2
2
  # Docs: https://nurseandrea.io/docs
3
3
  #
4
- # Environment variables required:
5
- # NURSE_ANDREA_TOKEN — your account ingest token (from nurseandrea.io/dashboard/settings)
6
- # NURSE_ANDREA_HOST — NurseAndrea endpoint (default: https://nurseandrea.io)
7
- # Set to http://localhost:4500 for local development
8
- # Set to https://staging.nurseandrea.io for staging
4
+ # Required environment variable:
5
+ # NURSE_ANDREA_ORG_TOKEN — your organization's ingest token
6
+ # (from nurseandrea.io/dashboard/settings)
7
+ # Optional environment variables:
8
+ # NURSE_ANDREA_HOST — NurseAndrea endpoint (default: https://nurseandrea.io)
9
+ # Set to http://localhost:4500 for local development
10
+ # Set to https://staging.nurseandrea.io for staging
11
+ #
12
+ # workspace_slug identifies which workspace within your org receives ingest.
13
+ # Lowercase letters, numbers, hyphens; starts with a letter; 1-64 chars.
14
+ # A new slug auto-creates as a pending workspace on first ingest.
9
15
 
10
16
  NurseAndrea.configure do |c|
11
- c.token = ENV["NURSE_ANDREA_TOKEN"]
12
- c.host = ENV.fetch("NURSE_ANDREA_HOST", "https://nurseandrea.io")
13
- c.service_name = ENV.fetch("NURSE_ANDREA_SERVICE_NAME", "<%= app_name %>")
14
- c.enabled = !Rails.env.test?
15
- c.log_level = :warn # Set to :debug to see all intercepted logs
17
+ c.org_token = ENV["NURSE_ANDREA_ORG_TOKEN"]
18
+ c.workspace_slug = "<%= app_name %>"
19
+ c.environment = ENV.fetch("RAILS_ENV", "production")
20
+ c.host = ENV.fetch("NURSE_ANDREA_HOST", "https://nurseandrea.io")
21
+ c.service_name = ENV.fetch("NURSE_ANDREA_SERVICE_NAME", "<%= app_name %>")
22
+ c.enabled = !Rails.env.test?
23
+ c.log_level = :warn # Set to :debug to see all intercepted logs
16
24
  end
@@ -0,0 +1,47 @@
1
+ module NurseAndrea
2
+ # Sprint A D6 (GAP-10) — per-failure-mode boot messages. Lives
3
+ # outside the Railtie so the message map can be tested without
4
+ # booting Rails. The Railtie calls .message_for(config) from its
5
+ # warn-and-disable branches; the unit specs target this module
6
+ # directly.
7
+ #
8
+ # Pre-Sprint-A the Railtie emitted a single generic warn
9
+ # ("Configuration incomplete at logger wrap time — monitoring
10
+ # disabled. Ensure NurseAndrea.configure is called in
11
+ # config/initializers/nurse_andrea.rb with a valid token.")
12
+ # regardless of which field was missing. Operators had to grep
13
+ # the codebase to know what to fix. The per-cause messages below
14
+ # name the exact failure mode and the env var or setter to update.
15
+ module BootDiagnostics
16
+ GUIDANCE = {
17
+ missing_org_token:
18
+ "[NurseAndrea] org_token is not set — monitoring disabled. " \
19
+ "Set NURSE_ANDREA_ORG_TOKEN in your environment.",
20
+ missing_workspace_slug:
21
+ "[NurseAndrea] workspace_slug is not set — monitoring disabled. " \
22
+ "Set c.workspace_slug in config/initializers/nurse_andrea.rb.",
23
+ missing_environment:
24
+ "[NurseAndrea] environment is not set — monitoring disabled. " \
25
+ "Set c.environment in config/initializers/nurse_andrea.rb " \
26
+ "(production / staging / development).",
27
+ invalid_environment:
28
+ "[NurseAndrea] environment is invalid — monitoring disabled. " \
29
+ "Must be one of: production, staging, development.",
30
+ invalid_workspace_slug:
31
+ "[NurseAndrea] workspace_slug is invalid — monitoring disabled. " \
32
+ "Slug must be lowercase letters, numbers, and hyphens; " \
33
+ "start with a letter; 1-64 characters.",
34
+ missing_host:
35
+ "[NurseAndrea] host is not set — monitoring disabled. " \
36
+ "Set NURSE_ANDREA_HOST or leave the default (https://nurseandrea.io)."
37
+ }.freeze
38
+
39
+ DISABLED =
40
+ "[NurseAndrea] monitoring is disabled (c.enabled = false).".freeze
41
+
42
+ def self.message_for(config)
43
+ return DISABLED unless config.enabled?
44
+ GUIDANCE[config.validation_diagnostic]
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,14 @@
1
1
  module NurseAndrea
2
2
  class Configuration
3
- attr_accessor :api_key, :host, :timeout, :log_level, :batch_size,
3
+ SUPPORTED_ENVIRONMENTS = EnvironmentDetector::SUPPORTED
4
+
5
+ MIGRATION_MESSAGE =
6
+ "%<field>s is no longer supported in NurseAndrea SDK 1.0. " \
7
+ "Migrate to org_token + workspace_slug + environment. " \
8
+ "See https://docs.nurseandrea.io/sdk/migration"
9
+
10
+ attr_accessor :org_token, :workspace_slug, :environment, :host,
11
+ :timeout, :log_level, :batch_size,
4
12
  :flush_interval, :backfill_hours, :log_file_path,
5
13
  :enabled, :debug, :service_name,
6
14
  :sdk_version, :sdk_language,
@@ -14,6 +22,7 @@ module NurseAndrea
14
22
 
15
23
  def initialize
16
24
  @host = DEFAULT_HOST
25
+ @environment = EnvironmentDetector.detect
17
26
  @timeout = 5
18
27
  @log_level = :debug
19
28
  @batch_size = 100
@@ -35,13 +44,19 @@ module NurseAndrea
35
44
  @service_discovery = true
36
45
  @auto_connect = false
37
46
  @disable_continuous_scan = false
38
- @continuous_scan_interval = 5 * 60 # seconds
47
+ @continuous_scan_interval = 5 * 60
39
48
  end
40
49
 
41
- alias_method :token, :api_key
42
- alias_method :token=, :api_key=
50
+ %i[api_key token ingest_token].each do |legacy|
51
+ define_method(legacy) do
52
+ raise MigrationError, format(MIGRATION_MESSAGE, field: legacy)
53
+ end
54
+
55
+ define_method("#{legacy}=") do |_|
56
+ raise MigrationError, format(MIGRATION_MESSAGE, field: legacy)
57
+ end
58
+ end
43
59
 
44
- # All endpoint URLs derived from host
45
60
  def ingest_url = "#{normalised_host}/api/v1/ingest"
46
61
  def metrics_url = "#{normalised_host}/api/v1/metrics"
47
62
  def traces_url = "#{normalised_host}/api/v1/traces"
@@ -57,29 +72,75 @@ module NurseAndrea
57
72
  end
58
73
 
59
74
  def valid?
60
- !api_key.nil? && !api_key.to_s.strip.empty? && !host.nil?
75
+ validation_diagnostic.nil?
76
+ end
77
+
78
+ # Sprint A D6 (GAP-10) — returns nil when configuration is valid,
79
+ # otherwise returns a symbol identifying the first failure mode.
80
+ # The Railtie maps these symbols to operator-actionable stderr
81
+ # messages so a missing-org_token miss tells the operator to
82
+ # set the env var rather than emitting the generic
83
+ # "Configuration incomplete at logger wrap time" line.
84
+ #
85
+ # Order matters: most-likely-missing field first. Operators
86
+ # debugging from logs benefit from the first message being the
87
+ # most common cause.
88
+ def validation_diagnostic
89
+ return :missing_org_token if blank?(org_token)
90
+ return :missing_workspace_slug if blank?(workspace_slug)
91
+ return :missing_environment if blank?(environment)
92
+ return :invalid_environment unless SUPPORTED_ENVIRONMENTS.include?(environment)
93
+ return :invalid_workspace_slug unless SlugValidator.valid?(workspace_slug)
94
+ return :missing_host if host.nil?
95
+ nil
61
96
  end
62
97
 
63
98
  def validate!
64
- unless valid?
65
- raise NurseAndrea::ConfigurationError,
66
- "[NurseAndrea] Configuration invalid. " \
67
- "Set NURSE_ANDREA_TOKEN and NURSE_ANDREA_HOST, then call " \
68
- "NurseAndrea.configure in config/initializers/nurse_andrea.rb"
99
+ raise_config_error("org_token is required") if blank?(org_token)
100
+ raise_config_error("workspace_slug is required") if blank?(workspace_slug)
101
+ raise_config_error("environment is required") if blank?(environment)
102
+
103
+ unless SUPPORTED_ENVIRONMENTS.include?(environment)
104
+ raise_config_error(
105
+ "environment must be one of #{SUPPORTED_ENVIRONMENTS.join(', ')} " \
106
+ "(got #{environment.inspect})"
107
+ )
69
108
  end
109
+
110
+ unless SlugValidator.valid?(workspace_slug)
111
+ raise_config_error(
112
+ "workspace_slug #{workspace_slug.inspect} is invalid. " \
113
+ "#{SlugValidator::HUMAN_READABLE_RULES}"
114
+ )
115
+ end
116
+
70
117
  self
71
118
  end
72
119
 
73
120
  private
74
121
 
122
+ def blank?(value)
123
+ value.nil? || value.to_s.strip.empty?
124
+ end
125
+
126
+ def raise_config_error(message)
127
+ raise ConfigurationError, "[NurseAndrea] #{message}"
128
+ end
129
+
75
130
  def normalised_host
76
131
  host.to_s.chomp("/")
77
132
  end
78
133
 
79
134
  def default_service_name
80
- ENV["RAILWAY_SERVICE_NAME"].then { |v| v&.strip.presence } ||
81
- ENV["NURSE_ANDREA_SERVICE_NAME"].then { |v| v&.strip.presence } ||
82
- rails_app_name
135
+ first_present_env("RAILWAY_SERVICE_NAME", "NURSE_ANDREA_SERVICE_NAME") || rails_app_name
136
+ end
137
+
138
+ def first_present_env(*names)
139
+ names.each do |n|
140
+ v = ENV[n].to_s.strip
141
+ return v unless v.empty?
142
+ end
143
+ nil
83
144
  end
84
145
 
85
146
  def rails_app_name
@@ -0,0 +1,33 @@
1
+ module NurseAndrea
2
+ class EnvironmentDetector
3
+ SUPPORTED = %w[production staging development].freeze
4
+
5
+ class << self
6
+ def detect
7
+ raw = ENV["RAILS_ENV"] || ENV["RACK_ENV"]
8
+ return "production" if raw.nil? || raw.empty?
9
+
10
+ return raw if SUPPORTED.include?(raw)
11
+
12
+ warn_unsupported(raw)
13
+ "production"
14
+ end
15
+
16
+ def reset_warning!
17
+ @warned = false
18
+ end
19
+
20
+ private
21
+
22
+ def warn_unsupported(value)
23
+ return if @warned
24
+
25
+ @warned = true
26
+ $stderr.puts(
27
+ "[NurseAndrea] Detected environment '#{value}' is not in the " \
28
+ "supported set #{SUPPORTED.inspect}. Falling back to 'production'."
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,7 @@
1
+ module NurseAndrea
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+
6
+ class MigrationError < ConfigurationError; end
7
+ end
@@ -4,35 +4,125 @@ require "json"
4
4
 
5
5
  module NurseAndrea
6
6
  class HttpClient
7
+ REJECTION_WARNING_THRESHOLD = 5
8
+ REJECTION_STATUSES = [ 401, 403, 422, 429 ].freeze
9
+
10
+ @@consecutive_rejections = 0
11
+ @@warned_for_error = nil
12
+ @@mutex = Mutex.new
13
+
14
+ class << self
15
+ def reset_rejection_state!
16
+ @@mutex.synchronize do
17
+ @@consecutive_rejections = 0
18
+ @@warned_for_error = nil
19
+ end
20
+ end
21
+ end
22
+
7
23
  def initialize
8
- @api_key = NurseAndrea.config.api_key
9
- @timeout = NurseAndrea.config.timeout
24
+ @config = NurseAndrea.config
10
25
  end
11
26
 
12
27
  def post(url, body)
13
- uri = URI.parse(url)
28
+ uri = URI.parse(url)
14
29
  http = Net::HTTP.new(uri.host, uri.port)
15
- http.use_ssl = uri.scheme == "https"
16
- http.open_timeout = @timeout
17
- http.read_timeout = @timeout
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = @config.timeout
32
+ http.read_timeout = @config.timeout
18
33
 
19
34
  request = Net::HTTP::Post.new(uri.path)
20
- request["Content-Type"] = "application/json"
21
- request["Authorization"] = "Bearer #{@api_key}"
22
- request["User-Agent"] = "nurse_andrea-ruby/#{NurseAndrea::VERSION}"
35
+ build_headers.each { |k, v| request[k] = v }
23
36
  request.body = body.to_json
24
37
 
25
38
  response = http.request(request)
26
- success = response.code.to_i.between?(200, 299)
27
-
28
- if NurseAndrea.config.debug && !success
29
- warn "[NurseAndrea] HTTP #{response.code} from #{uri}: #{response.body.to_s[0..200]}"
30
- end
39
+ handle_response(response, uri)
31
40
 
32
- success
41
+ response.code.to_i.between?(200, 299)
33
42
  rescue => e
34
- warn "[NurseAndrea] HTTP error posting to #{url}: #{e.class}: #{e.message}" if NurseAndrea.config.debug
43
+ warn "[NurseAndrea] HTTP error posting to #{url}: #{e.class}: #{e.message}" if @config.debug
35
44
  false
36
45
  end
46
+
47
+ private
48
+
49
+ def build_headers
50
+ {
51
+ "Content-Type" => "application/json",
52
+ "Authorization" => "Bearer #{@config.org_token}",
53
+ "X-NurseAndrea-Workspace" => @config.workspace_slug.to_s,
54
+ "X-NurseAndrea-Environment" => @config.environment.to_s,
55
+ "X-NurseAndrea-SDK" => "#{@config.sdk_language}/#{@config.sdk_version}",
56
+ # Sprint C — replay-mitigation timestamp. Server validates the
57
+ # value is within ±5 minutes when the header is present; SDKs
58
+ # older than 1.2.0 don't send it and the server accepts
59
+ # gracefully. See docs/sdk/payload-format.md §2 + SECURITY.md.
60
+ "X-NurseAndrea-Timestamp" => Time.now.to_i.to_s,
61
+ "User-Agent" => "nurse_andrea-ruby/#{NurseAndrea::VERSION}"
62
+ }
63
+ end
64
+
65
+ def handle_response(response, uri)
66
+ status = response.code.to_i
67
+
68
+ if status.between?(200, 299)
69
+ @@mutex.synchronize do
70
+ @@consecutive_rejections = 0
71
+ @@warned_for_error = nil
72
+ end
73
+ return
74
+ end
75
+
76
+ if @config.debug
77
+ warn "[NurseAndrea] HTTP #{status} from #{uri}: #{response.body.to_s[0..200]}"
78
+ end
79
+
80
+ return unless REJECTION_STATUSES.include?(status)
81
+
82
+ @@mutex.synchronize do
83
+ @@consecutive_rejections += 1
84
+ if @@consecutive_rejections >= REJECTION_WARNING_THRESHOLD
85
+ surface_rejection_warning(response, status)
86
+ end
87
+ end
88
+ end
89
+
90
+ def surface_rejection_warning(response, status)
91
+ body = JSON.parse(response.body) rescue {}
92
+ error = body.is_a?(Hash) ? body["error"].to_s : ""
93
+ return if @@warned_for_error == error
94
+
95
+ @@warned_for_error = error
96
+ message = body.is_a?(Hash) ? body["message"].to_s : ""
97
+
98
+ $stderr.puts(
99
+ "[NurseAndrea] Ingest rejected (#{REJECTION_WARNING_THRESHOLD}+ consecutive). " \
100
+ "Status: #{status} Error: #{error.empty? ? '(unknown)' : error}. " \
101
+ "#{guidance_for(error)}#{message.empty? ? '' : " Details: #{message}"}"
102
+ )
103
+ end
104
+
105
+ def guidance_for(error)
106
+ case error
107
+ when "invalid_org_token"
108
+ "Check NURSE_ANDREA_ORG_TOKEN."
109
+ when "workspace_rejected"
110
+ "Restore the workspace in the dashboard or change workspace_slug."
111
+ when "workspace_limit_exceeded"
112
+ "Org has reached its workspace limit. Reject unused workspaces or upgrade plan."
113
+ when "auto_create_disabled"
114
+ "Auto-create disabled. Create the workspace explicitly in the dashboard before ingesting."
115
+ when "environment_not_accepted_by_this_install"
116
+ "Environment '#{@config.environment}' not accepted by NurseAndrea at #{@config.host}. Check NURSE_ANDREA_HOST."
117
+ when "invalid_workspace_slug"
118
+ SlugValidator::HUMAN_READABLE_RULES
119
+ when "similar_slug_exists"
120
+ "A similar slug already exists in this org. Did you mean an existing one?"
121
+ when "creation_rate_limit_exceeded", "rate_limited"
122
+ "Workspace creation rate limit hit. Existing workspaces still ingesting normally."
123
+ else
124
+ ""
125
+ end
126
+ end
37
127
  end
38
128
  end
@@ -2,6 +2,11 @@ require "rails/railtie"
2
2
 
3
3
  module NurseAndrea
4
4
  class Railtie < Rails::Railtie
5
+ # Sprint A D6 (GAP-10) — per-failure-mode boot messages. The
6
+ # message map + resolver live in NurseAndrea::BootDiagnostics so
7
+ # they can be unit-tested without booting Rails. The Railtie
8
+ # just calls into it from each warn-and-disable branch.
9
+
5
10
  # Runs after ALL initializers — including config/initializers/
6
11
  # This means NurseAndrea.configure works from config/initializers/ as expected
7
12
  initializer "nurse_andrea.wrap_logger", after: :load_config_initializers do
@@ -14,9 +19,7 @@ module NurseAndrea
14
19
  "(host: #{NurseAndrea.config.host}, " \
15
20
  "service: #{NurseAndrea.config.service_name || 'auto'})")
16
21
  else
17
- warn "[NurseAndrea] Configuration incomplete at logger wrap time — " \
18
- "monitoring disabled. Ensure NurseAndrea.configure is called " \
19
- "in config/initializers/nurse_andrea.rb with a valid token."
22
+ warn NurseAndrea::BootDiagnostics.message_for(NurseAndrea.config)
20
23
  end
21
24
  end
22
25
 
@@ -25,8 +28,7 @@ module NurseAndrea
25
28
  app.middleware.use NurseAndrea::MetricsMiddleware
26
29
  Rails.logger.info("[NurseAndrea] MetricsMiddleware inserted")
27
30
  else
28
- warn "[NurseAndrea] Skipping MetricsMiddleware — no token configured. " \
29
- "Ensure NurseAndrea.configure is called in config/initializers/nurse_andrea.rb"
31
+ warn NurseAndrea::BootDiagnostics.message_for(NurseAndrea.config)
30
32
  end
31
33
  end
32
34
 
@@ -1,13 +1,41 @@
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.
1
+ # PRIVACY POLICY: This file derives platform-identifying values from
2
+ # NurseAndrea.config (a user-supplied configuration). No raw env
3
+ # values, connection strings, credentials, or tokens are stored or
4
+ # transmitted. The configured host is the source of truth for what
5
+ # counts as "platform infrastructure"; nothing is hard-coded except
6
+ # the Rails-app-name pattern in platform_self?, which is a process-
7
+ # level self-identification (the SDK detecting it's been loaded
8
+ # inside the NurseAndrea Rails app for self-instrumentation) and
9
+ # does not match against user-supplied URLs.
7
10
 
11
+ # Suppresses discovery emission when the SDK is loaded inside
12
+ # NurseAndrea itself, OR when a discovered connection target equals
13
+ # NurseAndrea's own configured infrastructure host. Both the
14
+ # InstrumentationSubscriber (hook-based) and the ManagedServiceScanner
15
+ # (env-based) must consult this filter before adding to
16
+ # NurseAndrea.component_discoveries — otherwise the platform's own
17
+ # infrastructure shows up as proposed components on every workspace
18
+ # dashboard.
19
+ #
20
+ # R-14 compliance: host_matches? derives its match indicator from
21
+ # NurseAndrea.config.host rather than hard-coding "nurseandrea.io"
22
+ # substring matching. This means:
23
+ # - prod (host=https://nurseandrea.io) → indicator "nurseandrea.io"
24
+ # - staging (host=https://staging.nurseandrea.io) → indicator "staging.nurseandrea.io"
25
+ # - dev (host=http://localhost:4500) → indicator "localhost"
26
+ # - tenant-specific (host=https://acme.example) → indicator "acme.example"
27
+ # The hard-coded substring approach used pre-R-14 broke dev (no
28
+ # "nurseandrea" in "localhost:4500") and would have broken any
29
+ # future tenant-specific ingest URLs.
8
30
  module NurseAndrea
9
31
  module SelfFilter
10
- SELF_INDICATORS = %w[nurseandrea nurse-andrea nurse_andrea].freeze
32
+ # Hard-coded fallback retained ONLY for platform_self? — that
33
+ # check matches the SDK's OWN Rails app name (process-level self-
34
+ # identification), which has no configured-URL equivalent. The
35
+ # R-14 anti-pattern (hard-coding customer URL substrings) does
36
+ # not apply here because the comparison target is the SDK's own
37
+ # environment, not a user-supplied URL.
38
+ PLATFORM_NAME_INDICATORS = %w[nurseandrea nurse-andrea nurse_andrea].freeze
11
39
 
12
40
  class << self
13
41
  def platform_self?
@@ -20,17 +48,32 @@ module NurseAndrea
20
48
  end
21
49
 
22
50
  def host_matches?(*candidates)
51
+ indicator = configured_host_indicator
52
+ return false if indicator.nil? || indicator.empty?
53
+
23
54
  candidates.compact.map(&:to_s).map(&:downcase).any? do |s|
24
- SELF_INDICATORS.any? { |i| s.include?(i) }
55
+ s.include?(indicator)
25
56
  end
26
57
  end
27
58
 
28
59
  private
29
60
 
61
+ def configured_host_indicator
62
+ return nil unless NurseAndrea.respond_to?(:config)
63
+
64
+ host_url = NurseAndrea.config&.host
65
+ return nil if host_url.nil? || host_url.to_s.empty?
66
+
67
+ # Extract just the hostname: strip scheme, port, path.
68
+ host_url.to_s.downcase
69
+ .sub(%r{\Ahttps?://}, "")
70
+ .sub(%r{[:/].*\z}, "")
71
+ end
72
+
30
73
  def compute_platform_self
31
74
  return false unless defined?(Rails) && Rails.application
32
75
  app_name = Rails.application.class.module_parent_name.to_s.downcase
33
- SELF_INDICATORS.any? { |i| app_name.include?(i) }
76
+ PLATFORM_NAME_INDICATORS.any? { |i| app_name.include?(i) }
34
77
  rescue
35
78
  false
36
79
  end
@@ -0,0 +1,15 @@
1
+ module NurseAndrea
2
+ class SlugValidator
3
+ PATTERN = /\A[a-z][a-z0-9\-]{0,63}\z/
4
+
5
+ HUMAN_READABLE_RULES =
6
+ "Workspace slugs must be lowercase letters, numbers, or hyphens. " \
7
+ "Must start with a letter. 1-64 characters."
8
+
9
+ def self.valid?(slug)
10
+ return false if slug.nil? || slug.to_s.empty?
11
+
12
+ slug.to_s.match?(PATTERN)
13
+ end
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module NurseAndrea
2
- VERSION = "0.2.5"
2
+ VERSION = "1.4.0"
3
3
  end
data/lib/nurse_andrea.rb CHANGED
@@ -1,4 +1,41 @@
1
+ # Sprint D D1 (GAP-09) — Rack-compatible core. The requires below
2
+ # divide into two layers:
3
+ #
4
+ # 1. Rack-compatible core (this block).
5
+ # Loads in any Ruby process — Sinatra, plain Rack, background
6
+ # worker, non-web service. Files in this layer reach into Rails
7
+ # ONLY behind `defined?(Rails)` / `defined?(ActiveSupport)`
8
+ # runtime guards inside methods, so requiring them in a non-
9
+ # Rails context succeeds and the methods short-circuit at call
10
+ # time.
11
+ #
12
+ # 2. Rails-only layer (the second block, guarded by Rails presence).
13
+ # Files here unconditionally require `rails/*` or
14
+ # `active_support/*` at the top of the file and therefore would
15
+ # raise LoadError outside a Rails app.
16
+ #
17
+ # Audit findings that drove this layout — surprises documented for
18
+ # future maintainers:
19
+ #
20
+ # * `log_interceptor`, `instrumentation_subscriber`, `query_subscriber`,
21
+ # `backfill`, `queue_depth_reporter`, `self_filter` all *use*
22
+ # Rails / ActiveSupport / SolidQueue / Sidekiq but only behind
23
+ # `defined?` guards inside methods. They are Rack-compatible at
24
+ # load time.
25
+ # * `metrics_middleware` is plain Rack middleware — it touches the
26
+ # env hash, never a Rails request object.
27
+ # * `job_instrumentation` is the only file in the historical
28
+ # unconditional list whose top-level requires (active_support/
29
+ # concern) actually require Rails. It moves below.
30
+ #
31
+ # The install generator at `lib/generators/nurse_andrea/install` is
32
+ # auto-discovered by Rails when `rails generate` runs — it is never
33
+ # required from this file, so it stays where it is and no guard is
34
+ # needed here.
1
35
  require "nurse_andrea/version"
36
+ require "nurse_andrea/errors"
37
+ require "nurse_andrea/slug_validator"
38
+ require "nurse_andrea/environment_detector"
2
39
  require "nurse_andrea/configuration"
3
40
  require "nurse_andrea/http_client"
4
41
  require "nurse_andrea/log_interceptor"
@@ -6,7 +43,6 @@ require "nurse_andrea/log_shipper"
6
43
  require "nurse_andrea/metrics_middleware"
7
44
  require "nurse_andrea/metrics_shipper"
8
45
  require "nurse_andrea/backfill"
9
- require "nurse_andrea/job_instrumentation"
10
46
  require "nurse_andrea/queue_depth_reporter"
11
47
  require "nurse_andrea/query_subscriber"
12
48
  require "nurse_andrea/sanitizer"
@@ -18,13 +54,18 @@ require "nurse_andrea/memory_sampler"
18
54
  require "nurse_andrea/deploy"
19
55
  require "nurse_andrea/self_filter"
20
56
  require "nurse_andrea/continuous_scanner"
57
+ require "nurse_andrea/boot_diagnostics"
21
58
 
22
- require "nurse_andrea/railtie" if defined?(Rails::Railtie)
23
- require "nurse_andrea/engine" if defined?(Rails::Engine)
59
+ # Rails-only layer. Engine and Railtie pull in `rails/engine` /
60
+ # `rails/railtie` at the top of their files; job_instrumentation
61
+ # pulls in `active_support/concern`. Each guard checks for the
62
+ # corresponding base class so the require is skipped cleanly in
63
+ # non-Rails processes.
64
+ require "nurse_andrea/job_instrumentation" if defined?(ActiveSupport::Concern)
65
+ require "nurse_andrea/railtie" if defined?(Rails::Railtie)
66
+ require "nurse_andrea/engine" if defined?(Rails::Engine)
24
67
 
25
68
  module NurseAndrea
26
- class ConfigurationError < StandardError; end
27
-
28
69
  class << self
29
70
  def configure
30
71
  yield(config)
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.5
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ago AI LLC
@@ -26,11 +26,14 @@ files:
26
26
  - lib/nurse_andrea.rb
27
27
  - lib/nurse_andrea/DATA_PRIVACY_POLICY.rb
28
28
  - lib/nurse_andrea/backfill.rb
29
+ - lib/nurse_andrea/boot_diagnostics.rb
29
30
  - lib/nurse_andrea/component_telemetry.rb
30
31
  - lib/nurse_andrea/configuration.rb
31
32
  - lib/nurse_andrea/continuous_scanner.rb
32
33
  - lib/nurse_andrea/deploy.rb
33
34
  - lib/nurse_andrea/engine.rb
35
+ - lib/nurse_andrea/environment_detector.rb
36
+ - lib/nurse_andrea/errors.rb
34
37
  - lib/nurse_andrea/http_client.rb
35
38
  - lib/nurse_andrea/instrumentation_subscriber.rb
36
39
  - lib/nurse_andrea/job_instrumentation.rb
@@ -46,6 +49,7 @@ files:
46
49
  - lib/nurse_andrea/railtie.rb
47
50
  - lib/nurse_andrea/sanitizer.rb
48
51
  - lib/nurse_andrea/self_filter.rb
52
+ - lib/nurse_andrea/slug_validator.rb
49
53
  - lib/nurse_andrea/version.rb
50
54
  - nurse_andrea.gemspec
51
55
  homepage: https://nurseandrea.io