nurse_andrea 0.1.2
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 +7 -0
- data/CHANGELOG.md +25 -0
- data/README.md +46 -0
- data/app/controllers/nurse_andrea/status_controller.rb +26 -0
- data/config/routes.rb +3 -0
- data/lib/generators/nurse_andrea/install/install_generator.rb +26 -0
- data/lib/generators/nurse_andrea/install/templates/nurse_andrea.rb.tt +16 -0
- data/lib/nurse_andrea/backfill.rb +116 -0
- data/lib/nurse_andrea/configuration.rb +49 -0
- data/lib/nurse_andrea/engine.rb +7 -0
- data/lib/nurse_andrea/http_client.rb +38 -0
- data/lib/nurse_andrea/log_interceptor.rb +49 -0
- data/lib/nurse_andrea/log_shipper.rb +79 -0
- data/lib/nurse_andrea/metrics_middleware.rb +40 -0
- data/lib/nurse_andrea/metrics_shipper.rb +78 -0
- data/lib/nurse_andrea/railtie.rb +27 -0
- data/lib/nurse_andrea/version.rb +3 -0
- data/lib/nurse_andrea.rb +27 -0
- data/nurse_andrea.gemspec +28 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0ab519a0523b2ddcd45d545236cb6b29f9d4ed1013d67cd771597838dc091cab
|
|
4
|
+
data.tar.gz: eed1931c5dbe44eeb9ee91ea5d2eb3df391681d6972a593d6c8f0faab0f9b733
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2c01153fec0178129a99b5fad4ce5e680006e40022f35b7f10531f5a7150a5247286239dd598af0129f2eb0af4ecf186e33af823f13f65aec1e6308b8d5b8bb5
|
|
7
|
+
data.tar.gz: 1e90759917304321798cea006be838067d9c5f0668a8363a0a29e672721fd70d10b169f4459615e869df0bc1d39227dfdb8b1d108180f18755ec788e87137f81
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
## [0.1.2] - 2026-04-05
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- `NurseAndrea::Configuration#host` — configurable base URL for all SDK endpoints. Defaults to `https://nurseandrea.io`.
|
|
5
|
+
- All ingest, metrics, traces, and handshake URLs are now derived from `host`. Nothing is hardcoded.
|
|
6
|
+
- `token` / `token=` aliases for `api_key` / `api_key=`
|
|
7
|
+
|
|
8
|
+
### Migration
|
|
9
|
+
Add `c.host = ENV.fetch("NURSE_ANDREA_HOST", "https://nurseandrea.io")` to your initializer.
|
|
10
|
+
|
|
11
|
+
## [0.1.1] - 2026-04-05
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- LogInterceptor now captures OpenTelemetry trace_id and span_id in log metadata when an OTel span is active
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2026-04-03
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Initial release
|
|
20
|
+
- Log shipping via background thread (thread-safe queue, configurable batch size and flush interval)
|
|
21
|
+
- Request metrics via Rack middleware (duration, status, normalized path)
|
|
22
|
+
- Log backfill on first connection (configurable hours, JSON and plaintext log formats)
|
|
23
|
+
- Handshake/status endpoint at `/nurse_andrea/status`
|
|
24
|
+
- Rails install generator (`rails generate nurse_andrea:install`)
|
|
25
|
+
- Zero runtime dependencies beyond Ruby stdlib
|
data/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# NurseAndrea Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby gem for [NurseAndrea](https://nurseandrea.com) — observability for Rails startups.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "nurse_andrea"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
rails generate nurse_andrea:install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Set your API key:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export NURSE_ANDREA_API_KEY="your_token_from_dashboard"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
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
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
```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?
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Version history
|
|
45
|
+
|
|
46
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class StatusController < ActionController::API
|
|
3
|
+
def show
|
|
4
|
+
render json: {
|
|
5
|
+
status: "ok",
|
|
6
|
+
version: NurseAndrea::VERSION,
|
|
7
|
+
rails_version: defined?(Rails) ? Rails::VERSION::STRING : "n/a",
|
|
8
|
+
ruby_version: RUBY_VERSION,
|
|
9
|
+
environment: defined?(Rails) ? Rails.env : "unknown",
|
|
10
|
+
integration_token: masked_token,
|
|
11
|
+
log_shipper_running: NurseAndrea::LogShipper.instance.running?,
|
|
12
|
+
metrics_running: NurseAndrea::MetricsShipper.instance.running?,
|
|
13
|
+
timestamp: Time.now.utc.iso8601,
|
|
14
|
+
capabilities: %w[logs metrics backfill handshake]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def masked_token
|
|
21
|
+
token = NurseAndrea.config.api_key.to_s
|
|
22
|
+
return "not_configured" if token.empty?
|
|
23
|
+
"#{token[0..7]}..."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module NurseAndrea
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
desc "Creates a NurseAndrea initializer and mounts the status endpoint"
|
|
8
|
+
|
|
9
|
+
def create_initializer
|
|
10
|
+
template "nurse_andrea.rb.tt", "config/initializers/nurse_andrea.rb"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def mount_engine
|
|
14
|
+
route 'mount NurseAndrea::Engine => "/nurse_andrea"'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show_next_steps
|
|
18
|
+
say "\nNurseAndrea installed!", :green
|
|
19
|
+
say "Next steps:", :bold
|
|
20
|
+
say " 1. Set NURSE_ANDREA_API_KEY in your environment"
|
|
21
|
+
say " 2. Get your API key from: https://app.nurseandrea.com/dashboard/integrations"
|
|
22
|
+
say " 3. Deploy and visit your onboarding wizard to verify the connection\n"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
NurseAndrea.configure do |c|
|
|
2
|
+
# Required: your integration token from the NurseAndrea dashboard
|
|
3
|
+
c.token = ENV["NURSE_ANDREA_TOKEN"]
|
|
4
|
+
|
|
5
|
+
# Required: the NurseAndrea host for this environment
|
|
6
|
+
c.host = ENV.fetch("NURSE_ANDREA_HOST", "https://nurseandrea.io")
|
|
7
|
+
|
|
8
|
+
# Optional: minimum log level to ship (default: :debug — ships everything)
|
|
9
|
+
# c.log_level = :warn
|
|
10
|
+
|
|
11
|
+
# Optional: disable in certain environments
|
|
12
|
+
# c.enabled = !Rails.env.test?
|
|
13
|
+
|
|
14
|
+
# Optional: how many hours of log history to backfill on first connect (default: 24)
|
|
15
|
+
# c.backfill_hours = 24
|
|
16
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module NurseAndrea
|
|
5
|
+
class Backfill
|
|
6
|
+
BATCH_SIZE = 500
|
|
7
|
+
MARKER_FILE = ".nurse_andrea_backfill_done"
|
|
8
|
+
|
|
9
|
+
def self.run_async!
|
|
10
|
+
Thread.new { new.run }.tap do |t|
|
|
11
|
+
t.abort_on_exception = false
|
|
12
|
+
t.name = "NurseAndrea::Backfill"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
return unless should_run?
|
|
18
|
+
|
|
19
|
+
log_file = resolve_log_file
|
|
20
|
+
return unless log_file && File.exist?(log_file)
|
|
21
|
+
|
|
22
|
+
entries = parse_log_file(log_file)
|
|
23
|
+
ship_in_batches(entries)
|
|
24
|
+
mark_complete!
|
|
25
|
+
rescue => e
|
|
26
|
+
warn "[NurseAndrea] Backfill error: #{e.message}" if NurseAndrea.config.debug
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def should_run?
|
|
32
|
+
return false unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
33
|
+
!File.exist?(marker_path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_log_file
|
|
37
|
+
return NurseAndrea.config.log_file_path if NurseAndrea.config.log_file_path
|
|
38
|
+
if defined?(Rails)
|
|
39
|
+
Rails.root.join("log", "#{Rails.env}.log").to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_log_file(path)
|
|
44
|
+
cutoff = Time.now.utc - (NurseAndrea.config.backfill_hours * 3600)
|
|
45
|
+
entries = []
|
|
46
|
+
|
|
47
|
+
File.foreach(path) do |line|
|
|
48
|
+
line = line.strip
|
|
49
|
+
next if line.empty?
|
|
50
|
+
|
|
51
|
+
entry = parse_line(line)
|
|
52
|
+
begin
|
|
53
|
+
next if entry[:timestamp] && Time.parse(entry[:timestamp]) < cutoff
|
|
54
|
+
rescue
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
entries << entry
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
entries
|
|
62
|
+
rescue => e
|
|
63
|
+
warn "[NurseAndrea] Log parse error: #{e.message}" if NurseAndrea.config.debug
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_line(line)
|
|
68
|
+
parsed = JSON.parse(line)
|
|
69
|
+
{
|
|
70
|
+
level: parsed["level"] || parsed["severity"] || "info",
|
|
71
|
+
message: parsed["message"] || parsed["msg"] || line,
|
|
72
|
+
timestamp: parsed["time"] || parsed["timestamp"] || Time.now.utc.iso8601(3),
|
|
73
|
+
metadata: { backfill: true }
|
|
74
|
+
}
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
{
|
|
77
|
+
level: extract_level(line),
|
|
78
|
+
message: line,
|
|
79
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
80
|
+
metadata: { backfill: true }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def extract_level(line)
|
|
85
|
+
if line.match?(/\b(ERROR|FATAL)\b/i) then "error"
|
|
86
|
+
elsif line.match?(/\bWARN\b/i) then "warn"
|
|
87
|
+
elsif line.match?(/\bDEBUG\b/i) then "debug"
|
|
88
|
+
else "info"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ship_in_batches(entries)
|
|
93
|
+
client = HttpClient.new
|
|
94
|
+
entries.each_slice(BATCH_SIZE) do |batch|
|
|
95
|
+
client.post(NurseAndrea.config.ingest_url, {
|
|
96
|
+
logs: batch.map { |e|
|
|
97
|
+
{ level: e[:level], message: e[:message], occurred_at: e[:timestamp], source: "backfill" }
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
sleep 0.1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mark_complete!
|
|
105
|
+
File.write(marker_path, Time.now.utc.iso8601)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def marker_path
|
|
109
|
+
if defined?(Rails)
|
|
110
|
+
Rails.root.join(MARKER_FILE).to_s
|
|
111
|
+
else
|
|
112
|
+
File.join(Dir.pwd, MARKER_FILE)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :api_key, :host, :timeout, :log_level, :batch_size,
|
|
4
|
+
:flush_interval, :backfill_hours, :log_file_path,
|
|
5
|
+
:enabled, :debug
|
|
6
|
+
|
|
7
|
+
LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
8
|
+
DEFAULT_HOST = "https://nurseandrea.io"
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@host = DEFAULT_HOST
|
|
12
|
+
@timeout = 5
|
|
13
|
+
@log_level = :debug
|
|
14
|
+
@batch_size = 100
|
|
15
|
+
@flush_interval = 10
|
|
16
|
+
@backfill_hours = 24
|
|
17
|
+
@log_file_path = nil
|
|
18
|
+
@enabled = true
|
|
19
|
+
@debug = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
alias_method :token, :api_key
|
|
23
|
+
alias_method :token=, :api_key=
|
|
24
|
+
|
|
25
|
+
# All endpoint URLs derived from host
|
|
26
|
+
def ingest_url = "#{normalised_host}/api/v1/ingest"
|
|
27
|
+
def metrics_url = "#{normalised_host}/api/v1/metrics"
|
|
28
|
+
def traces_url = "#{normalised_host}/api/v1/traces"
|
|
29
|
+
def handshake_url = "#{normalised_host}/api/v1/handshake"
|
|
30
|
+
|
|
31
|
+
def enabled?
|
|
32
|
+
@enabled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def min_log_level_int
|
|
36
|
+
LOG_LEVELS.fetch(log_level.to_sym, 0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def valid?
|
|
40
|
+
!api_key.nil? && !api_key.to_s.strip.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def normalised_host
|
|
46
|
+
host.to_s.chomp("/")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module NurseAndrea
|
|
6
|
+
class HttpClient
|
|
7
|
+
def initialize
|
|
8
|
+
@api_key = NurseAndrea.config.api_key
|
|
9
|
+
@timeout = NurseAndrea.config.timeout
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def post(url, body)
|
|
13
|
+
uri = URI.parse(url)
|
|
14
|
+
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
|
|
18
|
+
|
|
19
|
+
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}"
|
|
23
|
+
request.body = body.to_json
|
|
24
|
+
|
|
25
|
+
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
|
|
31
|
+
|
|
32
|
+
success
|
|
33
|
+
rescue => e
|
|
34
|
+
warn "[NurseAndrea] HTTP error posting to #{url}: #{e.class}: #{e.message}" if NurseAndrea.config.debug
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module NurseAndrea
|
|
4
|
+
class LogInterceptor < SimpleDelegator
|
|
5
|
+
SEV_LABEL = %w[DEBUG INFO WARN ERROR FATAL ANY].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(original_logger)
|
|
8
|
+
super(original_logger)
|
|
9
|
+
@min_level = NurseAndrea.config.min_log_level_int
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
%w[debug info warn error fatal].each do |level|
|
|
13
|
+
define_method(level) do |progname = nil, &block|
|
|
14
|
+
add(Logger.const_get(level.upcase), nil, progname, &block)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
19
|
+
result = super
|
|
20
|
+
|
|
21
|
+
return result unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
22
|
+
return result if severity.nil? || severity < @min_level
|
|
23
|
+
|
|
24
|
+
msg = message.nil? ? (block ? block.call : progname) : message
|
|
25
|
+
return result if msg.nil?
|
|
26
|
+
|
|
27
|
+
level_str = SEV_LABEL[severity]&.downcase || "unknown"
|
|
28
|
+
|
|
29
|
+
# Capture OpenTelemetry trace context if available
|
|
30
|
+
trace_metadata = {}
|
|
31
|
+
if defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:tracer_provider)
|
|
32
|
+
span_context = OpenTelemetry::Trace.current_span&.context
|
|
33
|
+
if span_context&.valid?
|
|
34
|
+
trace_metadata[:trace_id] = span_context.hex_trace_id
|
|
35
|
+
trace_metadata[:span_id] = span_context.hex_span_id
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
NurseAndrea::LogShipper.instance.enqueue(
|
|
40
|
+
level: level_str,
|
|
41
|
+
message: msg.to_s.strip,
|
|
42
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
43
|
+
metadata: { progname: progname&.to_s }.merge(trace_metadata).compact
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "singleton"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module NurseAndrea
|
|
5
|
+
class LogShipper
|
|
6
|
+
include Singleton
|
|
7
|
+
|
|
8
|
+
MAX_QUEUE_SIZE = 10_000
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@queue = []
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@thread = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start!
|
|
17
|
+
return if @thread&.alive?
|
|
18
|
+
@thread = Thread.new { flush_loop }
|
|
19
|
+
@thread.abort_on_exception = false
|
|
20
|
+
@thread.name = "NurseAndrea::LogShipper"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stop!
|
|
24
|
+
@thread&.kill
|
|
25
|
+
flush!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def running?
|
|
29
|
+
@thread&.alive? || false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enqueue(entry)
|
|
33
|
+
flush_now = @mutex.synchronize do
|
|
34
|
+
@queue.shift if @queue.size >= MAX_QUEUE_SIZE
|
|
35
|
+
@queue << entry
|
|
36
|
+
@queue.size >= NurseAndrea.config.batch_size
|
|
37
|
+
end
|
|
38
|
+
flush! if flush_now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def flush!
|
|
42
|
+
entries = @mutex.synchronize do
|
|
43
|
+
return if @queue.empty?
|
|
44
|
+
batch = @queue.dup
|
|
45
|
+
@queue.clear
|
|
46
|
+
batch
|
|
47
|
+
end
|
|
48
|
+
return if entries.nil? || entries.empty?
|
|
49
|
+
|
|
50
|
+
ship(entries)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def flush_loop
|
|
56
|
+
loop do
|
|
57
|
+
sleep NurseAndrea.config.flush_interval
|
|
58
|
+
flush!
|
|
59
|
+
rescue => e
|
|
60
|
+
warn "[NurseAndrea::LogShipper] Error in flush loop: #{e.message}" if NurseAndrea.config.debug
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ship(entries)
|
|
65
|
+
HttpClient.new.post(NurseAndrea.config.ingest_url, {
|
|
66
|
+
logs: entries.map { |e|
|
|
67
|
+
{
|
|
68
|
+
level: e[:level],
|
|
69
|
+
message: e[:message],
|
|
70
|
+
occurred_at: e[:timestamp],
|
|
71
|
+
source: "nurse_andrea_gem",
|
|
72
|
+
batch_id: SecureRandom.uuid,
|
|
73
|
+
payload: e[:metadata] || {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module NurseAndrea
|
|
2
|
+
class MetricsMiddleware
|
|
3
|
+
def initialize(app)
|
|
4
|
+
@app = app
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def call(env)
|
|
8
|
+
return @app.call(env) unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
9
|
+
return @app.call(env) if env["PATH_INFO"]&.start_with?("/nurse_andrea")
|
|
10
|
+
|
|
11
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
status, headers, body = @app.call(env)
|
|
13
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
|
|
14
|
+
|
|
15
|
+
NurseAndrea::MetricsShipper.instance.enqueue(
|
|
16
|
+
name: "http.server.duration",
|
|
17
|
+
value: duration_ms,
|
|
18
|
+
unit: "ms",
|
|
19
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
20
|
+
tags: {
|
|
21
|
+
http_method: env["REQUEST_METHOD"],
|
|
22
|
+
http_status: status.to_s,
|
|
23
|
+
http_path: normalize_path(env["PATH_INFO"])
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
[ status, headers, body ]
|
|
28
|
+
rescue => e
|
|
29
|
+
warn "[NurseAndrea] Middleware error: #{e.message}" if NurseAndrea.config.debug
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalize_path(path)
|
|
36
|
+
return "/" if path.nil? || path.empty?
|
|
37
|
+
path.gsub(%r{/\d+(/|$)}, '/:id\1')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "singleton"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module NurseAndrea
|
|
5
|
+
class MetricsShipper
|
|
6
|
+
include Singleton
|
|
7
|
+
|
|
8
|
+
BATCH_SIZE = 200
|
|
9
|
+
FLUSH_INTERVAL = 15
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@queue = []
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@thread = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start!
|
|
18
|
+
return if @thread&.alive?
|
|
19
|
+
@thread = Thread.new { flush_loop }
|
|
20
|
+
@thread.abort_on_exception = false
|
|
21
|
+
@thread.name = "NurseAndrea::MetricsShipper"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stop!
|
|
25
|
+
@thread&.kill
|
|
26
|
+
flush!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def running?
|
|
30
|
+
@thread&.alive? || false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enqueue(metric)
|
|
34
|
+
flush_now = @mutex.synchronize do
|
|
35
|
+
@queue << metric
|
|
36
|
+
@queue.size >= BATCH_SIZE
|
|
37
|
+
end
|
|
38
|
+
flush! if flush_now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def flush!
|
|
42
|
+
metrics = @mutex.synchronize do
|
|
43
|
+
return if @queue.empty?
|
|
44
|
+
batch = @queue.dup
|
|
45
|
+
@queue.clear
|
|
46
|
+
batch
|
|
47
|
+
end
|
|
48
|
+
return if metrics.nil? || metrics.empty?
|
|
49
|
+
|
|
50
|
+
ship(metrics)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def flush_loop
|
|
56
|
+
loop do
|
|
57
|
+
sleep FLUSH_INTERVAL
|
|
58
|
+
flush!
|
|
59
|
+
rescue => e
|
|
60
|
+
warn "[NurseAndrea::MetricsShipper] #{e.message}" if NurseAndrea.config.debug
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ship(metrics)
|
|
65
|
+
HttpClient.new.post(NurseAndrea.config.metrics_url, {
|
|
66
|
+
metrics: metrics.map { |m|
|
|
67
|
+
{
|
|
68
|
+
name: m[:name],
|
|
69
|
+
value: m[:value],
|
|
70
|
+
unit: m[:unit],
|
|
71
|
+
tags: m[:tags] || {},
|
|
72
|
+
occurred_at: m[:timestamp]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module NurseAndrea
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "nurse_andrea.insert_middleware", before: :build_middleware_stack do |app|
|
|
6
|
+
app.config.middleware.insert_before 0, NurseAndrea::MetricsMiddleware
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "nurse_andrea.wrap_logger", after: :initialize_logger do
|
|
10
|
+
next unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
11
|
+
|
|
12
|
+
Rails.logger = NurseAndrea::LogInterceptor.new(Rails.logger)
|
|
13
|
+
NurseAndrea::LogShipper.instance.start!
|
|
14
|
+
NurseAndrea::MetricsShipper.instance.start!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.after_initialize do
|
|
18
|
+
next unless NurseAndrea.config.enabled? && NurseAndrea.config.valid?
|
|
19
|
+
NurseAndrea::Backfill.run_async!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
at_exit do
|
|
23
|
+
NurseAndrea::LogShipper.instance.flush! rescue nil
|
|
24
|
+
NurseAndrea::MetricsShipper.instance.flush! rescue nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/nurse_andrea.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "nurse_andrea/version"
|
|
2
|
+
require "nurse_andrea/configuration"
|
|
3
|
+
require "nurse_andrea/http_client"
|
|
4
|
+
require "nurse_andrea/log_interceptor"
|
|
5
|
+
require "nurse_andrea/log_shipper"
|
|
6
|
+
require "nurse_andrea/metrics_middleware"
|
|
7
|
+
require "nurse_andrea/metrics_shipper"
|
|
8
|
+
require "nurse_andrea/backfill"
|
|
9
|
+
|
|
10
|
+
require "nurse_andrea/railtie" if defined?(Rails::Railtie)
|
|
11
|
+
require "nurse_andrea/engine" if defined?(Rails::Engine)
|
|
12
|
+
|
|
13
|
+
module NurseAndrea
|
|
14
|
+
class << self
|
|
15
|
+
def configure
|
|
16
|
+
yield(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def config
|
|
20
|
+
@config ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_config!
|
|
24
|
+
@config = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "lib/nurse_andrea/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "nurse_andrea"
|
|
5
|
+
spec.version = NurseAndrea::VERSION
|
|
6
|
+
spec.authors = [ "Ago AI LLC" ]
|
|
7
|
+
spec.email = [ "hello@nurseandrea.io" ]
|
|
8
|
+
spec.summary = "Observability SDK for Rails — ships logs and metrics to NurseAndrea"
|
|
9
|
+
spec.description = "One-line integration to send your Rails app's logs and metrics to the NurseAndrea observability platform."
|
|
10
|
+
spec.homepage = "https://nurseandrea.io"
|
|
11
|
+
spec.license = "MIT"
|
|
12
|
+
|
|
13
|
+
spec.required_ruby_version = ">= 3.1"
|
|
14
|
+
|
|
15
|
+
spec.metadata = {
|
|
16
|
+
"homepage_uri" => spec.homepage,
|
|
17
|
+
"source_code_uri" => "https://github.com/narteyb/nurse-andrea",
|
|
18
|
+
"changelog_uri" => "https://github.com/narteyb/nurse-andrea/blob/main/gems/nurse_andrea/CHANGELOG.md",
|
|
19
|
+
"rubygems_mfa_required" => "true"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
|
23
|
+
Dir["{app,config,lib}/**/*", "README.md", "CHANGELOG.md", "nurse_andrea.gemspec"]
|
|
24
|
+
.reject { |f| File.directory?(f) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
spec.require_paths = [ "lib" ]
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nurse_andrea
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ago AI LLC
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: One-line integration to send your Rails app's logs and metrics to the
|
|
13
|
+
NurseAndrea observability platform.
|
|
14
|
+
email:
|
|
15
|
+
- hello@nurseandrea.io
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- README.md
|
|
22
|
+
- app/controllers/nurse_andrea/status_controller.rb
|
|
23
|
+
- config/routes.rb
|
|
24
|
+
- lib/generators/nurse_andrea/install/install_generator.rb
|
|
25
|
+
- lib/generators/nurse_andrea/install/templates/nurse_andrea.rb.tt
|
|
26
|
+
- lib/nurse_andrea.rb
|
|
27
|
+
- lib/nurse_andrea/backfill.rb
|
|
28
|
+
- lib/nurse_andrea/configuration.rb
|
|
29
|
+
- lib/nurse_andrea/engine.rb
|
|
30
|
+
- lib/nurse_andrea/http_client.rb
|
|
31
|
+
- lib/nurse_andrea/log_interceptor.rb
|
|
32
|
+
- lib/nurse_andrea/log_shipper.rb
|
|
33
|
+
- lib/nurse_andrea/metrics_middleware.rb
|
|
34
|
+
- lib/nurse_andrea/metrics_shipper.rb
|
|
35
|
+
- lib/nurse_andrea/railtie.rb
|
|
36
|
+
- lib/nurse_andrea/version.rb
|
|
37
|
+
- nurse_andrea.gemspec
|
|
38
|
+
homepage: https://nurseandrea.io
|
|
39
|
+
licenses:
|
|
40
|
+
- MIT
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://nurseandrea.io
|
|
43
|
+
source_code_uri: https://github.com/narteyb/nurse-andrea
|
|
44
|
+
changelog_uri: https://github.com/narteyb/nurse-andrea/blob/main/gems/nurse_andrea/CHANGELOG.md
|
|
45
|
+
rubygems_mfa_required: 'true'
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 4.0.9
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Observability SDK for Rails — ships logs and metrics to NurseAndrea
|
|
63
|
+
test_files: []
|