salopulse 0.1.0 → 0.2.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: 508b79a0d71e8bf74c4c743df633f4c78d54607acf341587bcb6a29eeeb7f0c8
4
- data.tar.gz: 9b27e52989cebcdaa7f6b48f4b9b758422c362c1e91fc3f93e35e3c26e06030f
3
+ metadata.gz: e595f060867547b1a24600f74efd2381427496a9f52ed4c601043ecfda8aea9d
4
+ data.tar.gz: 8854fc8119f60edba1efdc9c2a03913a52a371ef4d512f3935b97f1811262876
5
5
  SHA512:
6
- metadata.gz: 9c6dd7aabc843b88d157244c9479ef89786d4efcf20c4b0db7330b596b259a5c9ede75e9bf491ed0e29459d60580d516794303274b97a99f8ebdc89159214278
7
- data.tar.gz: 760dc6b01a270fcf26ee5e90a8ad1c60fbb7951168baa1bb9caf654526d7ea6ab3e2d1b09c84bcf5368962707c60530a2bf5b1ec13620bf9ba070571112b73a7
6
+ metadata.gz: 3ba6e7d361a664acf07b52ffbc681f5d7a17eacc971259c3692e66d32a66c0a42d58b25790ecbe8e8d87076904d7f38156522a0fd12981fe93e19cf43eb30795
7
+ data.tar.gz: 46ebc72af0e1197d96590797add818733ddebfa56e844fc0b2f55d3be55b1a550a9cf544735fd207e2d80ef6c50aafe59a07e20443d0ae7e1304b016deda68f7
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## [0.2.0] - 2026-06-04
6
+
7
+ First public release of the rewritten Salopulse APM SDK. Bumped past the
8
+ legacy `salopulse` 0.1.x line on RubyGems.
9
+
10
+ ### Added (since legacy 0.1.x)
11
+
12
+ ### Added
13
+ - DSN parsing (`Salopulse::DSN`) with strict validation
14
+ - Thread-safe `Buffer` with drop-on-overflow
15
+ - `Transport` with retry/backoff (5xx + 429, max 3 attempts)
16
+ - Background `Flusher` thread with interval + batch_size flushing
17
+ - `RequestContext` (thread-local) for request_id correlation
18
+ - ActiveRecord SQL subscriber with schema/transaction/cached filtering
19
+ - Rack middleware for performance + exception capture
20
+ - N+1 detection within request scope (`n1_threshold`)
21
+ - PII `Sanitizer` for hash + header scrubbing
22
+ - `before_send` and `sample_rate` hooks
23
+ - Rails `Railtie` for automatic install
24
+ - Graceful `at_exit` shutdown with final flush
data/LICENSE CHANGED
@@ -1,28 +1,15 @@
1
-
2
- ---
3
-
4
- # 📄 **3. `LICENSE`
5
-
6
- ```text
7
1
  MIT License
8
2
 
9
- Copyright (c) 2025 Salo Company
10
- All rights reserved.
3
+ Copyright (c) 2026 Salopulse
11
4
 
12
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
13
6
  of this software and associated documentation files (the "Software"), to deal
14
- in the Software without restriction, including without limitation the rights
15
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
- copies of the Software, and to permit persons to whom the Software is
17
- furnished to do so, subject to the following conditions:
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
18
11
 
19
- The above copyright notice and this permission notice shall be included in
20
- all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
21
14
 
22
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
- THE SOFTWARE.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
data/README.md CHANGED
@@ -1,26 +1,82 @@
1
- # SaloPulse Ruby SDK
1
+ # Salopulse Ruby SDK
2
2
 
3
- SaloPulse Ruby SDK, Ruby ve Ruby on Rails projeleri için geliştirilmiş hafif ve modern bir **monitoring & analytics entegrasyon kütüphanesidir**.
3
+ Otomatik SQL, hata ve HTTP performans telemetrisi Ruby uygulamaları için Salopulse APM istemcisi.
4
4
 
5
- Bu SDK, uygulamanızdaki her HTTP isteğini otomatik olarak izler, performans ölçer ve verileri **SaloPulse Observability Platformu**na güvenli bir şekilde iletir.
5
+ ## Kurulum
6
6
 
7
- ---
7
+ ```ruby
8
+ # Gemfile
9
+ gem "salopulse"
10
+ ```
11
+
12
+ ```
13
+ bundle install
14
+ ```
8
15
 
9
- ## 🚀 Özellikler
16
+ ## Hızlı Başlangıç
17
+
18
+ ```ruby
19
+ # config/initializers/salopulse.rb
20
+ Salopulse.init(dsn: ENV["SALOPULSE_DSN"])
21
+ ```
10
22
 
11
- - Otomatik request yakalama (middleware)
12
- - Path, method, status, response time ölçümü
13
- - IP & User-Agent toplama
14
- - API key tabanlı güvenli kimliklendirme
15
- - SaloPulse backend’e JSON gönderimi
16
- - Non-blocking – uygulamanızı hiçbir zaman bozmaz
17
- - Production-ready – enterprise sistemler için uygundur
23
+ Rails varsa bu kadarı yeter — middleware ve ActiveRecord aboneliği otomatik kurulur.
18
24
 
19
- ---
25
+ ## Otomatik Yakalanan Olaylar
20
26
 
21
- ## 📦 Kurulum
27
+ - **SQL** — `ActiveSupport::Notifications` üzerinden her sorgu (schema/transaction/cached hariç)
28
+ - **Hata** — Rack middleware'den sızan tüm exception'lar
29
+ - **Performans** — Her HTTP request için süre + status
22
30
 
23
- ### 1. Gem ekle
31
+ ## Manuel API
24
32
 
25
33
  ```ruby
26
- gem "salopulse"
34
+ Salopulse.capture_exception(error, user_context: { user_id: 1 })
35
+ Salopulse.capture_message("cache miss", level: :warning)
36
+ Salopulse.set_user(id: 1, email: "a@b.com")
37
+ Salopulse.set_tag(:feature, "checkout-v2")
38
+ Salopulse.flush(timeout: 5)
39
+ Salopulse.close
40
+ ```
41
+
42
+ ## Konfigürasyon
43
+
44
+ | Seçenek | Varsayılan | Açıklama |
45
+ |--------------------|------------|------------------------------------------------|
46
+ | `dsn` | — | **Zorunlu.** `https://api_key@host` formatı |
47
+ | `release` | `nil` | Sürüm etiketi (genelde git SHA) |
48
+ | `environment` | `nil` | `production` / `staging` / ... |
49
+ | `sample_rate` | `1.0` | 0..1 arası örnekleme oranı |
50
+ | `flush_interval` | `5` sn | Background flush periyodu |
51
+ | `flush_batch_size` | `100` | Tek POST'ta gönderilen event sayısı |
52
+ | `n1_threshold` | `10` | Aynı fingerprint kaç defadan sonra N+1 sayılır |
53
+ | `max_buffer_size` | `10_000` | Buffer üst sınırı; aşılırsa silent drop |
54
+ | `before_send` | `nil` | `->(event) { event }`; `nil` dönerse atılır |
55
+ | `logger` | stdout | SDK iç logları için |
56
+ | `enabled` | `true` | `false` → SDK tamamen no-op |
57
+
58
+ ## Gizlilik
59
+
60
+ SDK aşağıdaki alanları otomatik maskeler (`[FILTERED]`):
61
+
62
+ - `password`, `password_confirmation`, `token`, `api_key`, `secret`
63
+ - `access_token`, `refresh_token`, `authorization`, `cookie`
64
+ - `credit_card`, `card_number`, `cvv`, `ssn`
65
+ - Header'lar: `Authorization`, `Cookie`, `X-Api-Key`, `X-Auth-Token`
66
+
67
+ ## Performans
68
+
69
+ - Tüm I/O background thread'inde — istek path'i bloklanmaz
70
+ - Buffer üst sınırlı (`max_buffer_size`); patlayıp uygulamayı çökertmez
71
+ - Transport hatalarında exponential backoff retry (max 3) — başarısızsa event düşer, uygulamaya hata sızmaz
72
+
73
+ ## Geliştirme
74
+
75
+ ```
76
+ bundle install
77
+ bundle exec rspec
78
+ ```
79
+
80
+ ## Lisans
81
+
82
+ MIT
@@ -0,0 +1,56 @@
1
+ require "thread"
2
+
3
+ module Salopulse
4
+ class Buffer
5
+ DEFAULT_MAX_SIZE = 10_000
6
+
7
+ def initialize(max_size: DEFAULT_MAX_SIZE)
8
+ @queue = Queue.new
9
+ @max_size = max_size
10
+ @mutex = Mutex.new
11
+ @dropped = 0
12
+ end
13
+
14
+ def push(event)
15
+ if @queue.size >= @max_size
16
+ @mutex.synchronize { @dropped += 1 }
17
+ return false
18
+ end
19
+ @queue.push(event)
20
+ true
21
+ end
22
+
23
+ def push_many(events)
24
+ events.each { |e| push(e) }
25
+ end
26
+
27
+ def drain(max: 100)
28
+ events = []
29
+ max.times do
30
+ break if @queue.empty?
31
+ begin
32
+ events << @queue.pop(true)
33
+ rescue ThreadError
34
+ break
35
+ end
36
+ end
37
+ events
38
+ end
39
+
40
+ def size
41
+ @queue.size
42
+ end
43
+
44
+ def empty?
45
+ @queue.empty?
46
+ end
47
+
48
+ def dropped_count
49
+ @mutex.synchronize { @dropped }
50
+ end
51
+
52
+ def reset_dropped!
53
+ @mutex.synchronize { @dropped = 0 }
54
+ end
55
+ end
56
+ end
@@ -1,52 +1,251 @@
1
- # frozen_string_literal: true
1
+ require "time"
2
+ require "singleton"
3
+ require_relative "version"
4
+ require_relative "configuration"
5
+ require_relative "dsn"
6
+ require_relative "buffer"
7
+ require_relative "transport"
8
+ require_relative "flusher"
9
+ require_relative "sanitizer"
10
+ require_relative "local_fingerprint"
11
+ require_relative "request_context"
2
12
 
3
- require 'net/http'
4
- require 'uri'
5
- require 'json'
6
-
7
- module SaloPulse
13
+ module Salopulse
8
14
  class Client
9
- class << self
10
- def send_event(payload)
11
- config = SaloPulse.config
15
+ include Singleton
16
+
17
+ attr_reader :configuration, :buffer, :transport, :flusher, :dsn
12
18
 
13
- return unless config.valid?
19
+ def initialize
20
+ @initialized = false
21
+ @mutex = Mutex.new
22
+ end
14
23
 
15
- uri = URI.parse(config.endpoint)
24
+ def init(options = {})
25
+ @mutex.synchronize do
26
+ return self if @initialized
16
27
 
17
- http = Net::HTTP.new(uri.host, uri.port)
18
- http.use_ssl = uri.scheme == 'https'
19
- http.read_timeout = config.timeout
20
- http.open_timeout = config.timeout
28
+ @configuration = Configuration.new
29
+ options.each { |k, v| @configuration.public_send("#{k}=", v) if @configuration.respond_to?("#{k}=") }
21
30
 
22
- request = Net::HTTP::Post.new(uri.request_uri)
23
- request['Content-Type'] = 'application/json'
24
- request['Accept'] = 'application/json'
31
+ return self unless @configuration.enabled
25
32
 
26
- body = payload.merge(
27
- api_key: config.api_key,
28
- environment: config.environment
33
+ @dsn = DSN.new(@configuration.dsn)
34
+ @buffer = Buffer.new(max_size: @configuration.max_buffer_size)
35
+ @transport = Transport.new(
36
+ dsn: @dsn,
37
+ sdk_version: Salopulse::VERSION,
38
+ logger: @configuration.logger
39
+ )
40
+ @flusher = Flusher.new(
41
+ buffer: @buffer,
42
+ transport: @transport,
43
+ interval: @configuration.flush_interval,
44
+ batch_size: @configuration.flush_batch_size,
45
+ logger: @configuration.logger
29
46
  )
47
+ @flusher.start
48
+
49
+ install_at_exit_hook
50
+
51
+ @initialized = true
52
+ self
53
+ end
54
+ end
55
+
56
+ def initialized?
57
+ @initialized
58
+ end
59
+
60
+ def uninitialized?
61
+ !@initialized
62
+ end
63
+
64
+ def disabled?
65
+ !@initialized || !@configuration&.enabled
66
+ end
30
67
 
31
- request.body = JSON.generate(body)
68
+ # --- Capture API ---------------------------------------------------------
32
69
 
33
- http.request(request)
34
- rescue StandardError => e
35
- log_error(e)
36
- nil
70
+ def capture_sql(query:, duration_ms:, rows_returned: nil)
71
+ return if disabled?
72
+ return if RequestContext.suppressed?
73
+ return unless sample?
74
+
75
+ ctx = RequestContext.current
76
+ fingerprint = LocalFingerprint.for(query)
77
+
78
+ event = build_event(
79
+ type: "sql",
80
+ data: {
81
+ "query" => query,
82
+ "duration_ms" => duration_ms.to_i,
83
+ "endpoint" => ctx&.dig(:endpoint),
84
+ "http_method" => ctx&.dig(:http_method),
85
+ "rows_returned" => rows_returned,
86
+ "n1_detected" => false
87
+ }.compact,
88
+ ctx: ctx,
89
+ extra_envelope: { "database_dialect" => detect_dialect }
90
+ )
91
+
92
+ if ctx
93
+ RequestContext.record_sql_event(event, fingerprint)
94
+ else
95
+ enqueue(event)
37
96
  end
97
+ end
98
+
99
+ def capture_exception(error, user_context: nil, environment_data: nil)
100
+ return if disabled?
101
+ return unless sample?
102
+
103
+ ctx = RequestContext.current
104
+ data = {
105
+ "error_class" => error.class.name,
106
+ "message" => error.message.to_s,
107
+ "stack_trace" => Array(error.backtrace).join("\n"),
108
+ "endpoint" => ctx&.dig(:endpoint),
109
+ "http_method" => ctx&.dig(:http_method)
110
+ }
111
+ user = user_context || ctx&.dig(:user)
112
+ data["user_context"] = Sanitizer.scrub_hash(user) if user
113
+ data["environment_data"] = Sanitizer.scrub_hash(environment_data) if environment_data
114
+
115
+ enqueue(build_event(type: "error", data: data.compact, ctx: ctx))
116
+ end
117
+
118
+ def capture_message(message, level: :info)
119
+ return if disabled?
120
+ return unless sample?
121
+
122
+ ctx = RequestContext.current
123
+ data = {
124
+ "error_class" => "Salopulse::Message",
125
+ "message" => message.to_s,
126
+ "stack_trace" => "",
127
+ "endpoint" => ctx&.dig(:endpoint),
128
+ "http_method" => ctx&.dig(:http_method),
129
+ "level" => level.to_s
130
+ }.compact
131
+ enqueue(build_event(type: "error", data: data, ctx: ctx))
132
+ end
133
+
134
+ def capture_performance(endpoint:, http_method:, duration_ms:, status_code:, cpu_usage: nil, memory_usage: nil)
135
+ return if disabled?
136
+ return unless sample?
137
+
138
+ ctx = RequestContext.current
139
+ data = {
140
+ "endpoint" => endpoint,
141
+ "http_method" => http_method,
142
+ "duration_ms" => duration_ms.to_i,
143
+ "status_code" => status_code.to_i
144
+ }
145
+ data["cpu_usage"] = cpu_usage if cpu_usage
146
+ data["memory_usage"] = memory_usage if memory_usage
147
+
148
+ enqueue(build_event(type: "performance", data: data, ctx: ctx))
149
+ end
150
+
151
+ # --- Request scope -------------------------------------------------------
38
152
 
39
- private
153
+ def flush_request_scope_events
154
+ ctx = RequestContext.current
155
+ return unless ctx
40
156
 
41
- def log_error(error)
42
- message = "[SaloPulse] Failed to send event: #{error.class}: #{error.message}"
157
+ threshold = @configuration.n1_threshold
158
+ counts = ctx[:sql_fingerprint_counts]
43
159
 
44
- if defined?(Rails)
45
- Rails.logger.debug(message)
46
- else
47
- warn(message)
160
+ ctx[:sql_events].each do |event, fingerprint|
161
+ if counts[fingerprint] >= threshold
162
+ event[:data]["n1_detected"] = true
48
163
  end
164
+ enqueue(event)
165
+ end
166
+ end
167
+
168
+ # --- Context helpers -----------------------------------------------------
169
+
170
+ def set_user(attrs)
171
+ RequestContext.set_user(attrs)
172
+ end
173
+
174
+ def set_tag(key, value)
175
+ RequestContext.set_tag(key, value)
176
+ end
177
+
178
+ # --- Lifecycle -----------------------------------------------------------
179
+
180
+ def flush(timeout: 5)
181
+ return 0 if disabled?
182
+ @flusher.flush_all(timeout: timeout)
183
+ end
184
+
185
+ def close
186
+ return unless @initialized
187
+ @flusher&.stop(timeout: 5)
188
+ @initialized = false
189
+ end
190
+
191
+ # Used by tests to wipe singleton state.
192
+ def reset!
193
+ close if @initialized
194
+ @configuration = nil
195
+ @buffer = nil
196
+ @transport = nil
197
+ @flusher = nil
198
+ @dsn = nil
199
+ @initialized = false
200
+ end
201
+
202
+ private
203
+
204
+ def enqueue(event)
205
+ return false unless @buffer
206
+ if @configuration.before_send
207
+ event = @configuration.before_send.call(event)
208
+ return false if event.nil?
209
+ end
210
+ @buffer.push(event)
211
+ end
212
+
213
+ def build_event(type:, data:, ctx:, extra_envelope: {})
214
+ envelope = {
215
+ "request_id" => ctx&.dig(:request_id),
216
+ "release" => @configuration.release,
217
+ "sdk" => { "version" => Salopulse::VERSION, "platform" => "ruby" },
218
+ "timestamp" => Time.now.utc.iso8601(3)
219
+ }.merge(extra_envelope).compact
220
+
221
+ { type: type, data: data, envelope: envelope }
222
+ end
223
+
224
+ def sample?
225
+ rate = @configuration.sample_rate.to_f
226
+ return true if rate >= 1.0
227
+ return false if rate <= 0.0
228
+ rand < rate
229
+ end
230
+
231
+ def detect_dialect
232
+ return "unknown" unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
233
+ adapter = ActiveRecord::Base.connection.adapter_name.to_s.downcase
234
+ case adapter
235
+ when /postgres/ then "postgres"
236
+ when /mysql/ then "mysql"
237
+ when /sqlite/ then "sqlite"
238
+ when /sqlserver|mssql/ then "mssql"
239
+ else "unknown"
49
240
  end
241
+ rescue StandardError
242
+ "unknown"
243
+ end
244
+
245
+ def install_at_exit_hook
246
+ return if @at_exit_installed
247
+ @at_exit_installed = true
248
+ at_exit { close rescue nil }
50
249
  end
51
250
  end
52
251
  end
@@ -1,19 +1,22 @@
1
- # frozen_string_literal: true
1
+ require "logger"
2
2
 
3
- module SaloPulse
3
+ module Salopulse
4
4
  class Configuration
5
- attr_accessor :api_key, :environment, :endpoint, :timeout, :enabled
5
+ attr_accessor :dsn, :release, :environment, :sample_rate,
6
+ :flush_interval, :flush_batch_size, :n1_threshold,
7
+ :before_send, :logger, :enabled, :max_buffer_size
6
8
 
7
9
  def initialize
8
- @api_key = ENV['SALO_PULSE_API_KEY']
9
- @environment = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
10
- @endpoint = ENV['SALO_PULSE_ENDPOINT'] || 'https://api.salopulse.com/v1/ingest'
11
- @timeout = 1.0
12
- @enabled = true
13
- end
14
-
15
- def valid?
16
- @enabled && @api_key && !@api_key.strip.empty?
10
+ @release = nil
11
+ @environment = nil
12
+ @sample_rate = 1.0
13
+ @flush_interval = 5
14
+ @flush_batch_size = 100
15
+ @n1_threshold = 10
16
+ @before_send = nil
17
+ @logger = Logger.new($stdout, level: Logger::WARN)
18
+ @enabled = true
19
+ @max_buffer_size = 10_000
17
20
  end
18
21
  end
19
22
  end
@@ -0,0 +1,28 @@
1
+ require "uri"
2
+ require_relative "error/invalid_dsn"
3
+
4
+ module Salopulse
5
+ class DSN
6
+ attr_reader :api_key, :ingest_url, :host
7
+
8
+ def initialize(dsn_string)
9
+ raise Salopulse::Error::InvalidDSN, "DSN boş" if dsn_string.nil? || dsn_string.to_s.empty?
10
+
11
+ uri =
12
+ begin
13
+ URI.parse(dsn_string)
14
+ rescue URI::InvalidURIError
15
+ raise Salopulse::Error::InvalidDSN, "geçersiz URL"
16
+ end
17
+
18
+ raise Salopulse::Error::InvalidDSN, "scheme https olmalı" unless uri.scheme == "https"
19
+ raise Salopulse::Error::InvalidDSN, "api_key eksik" if uri.userinfo.nil? || uri.userinfo.empty?
20
+ raise Salopulse::Error::InvalidDSN, "host eksik" if uri.host.nil? || uri.host.empty?
21
+
22
+ @api_key = uri.userinfo
23
+ @host = uri.host
24
+ port_part = (uri.port && uri.port != 443) ? ":#{uri.port}" : ""
25
+ @ingest_url = "https://#{uri.host}#{port_part}/api/v1/ingest"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ module Salopulse
2
+ module Error
3
+ class Base < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "base"
2
+
3
+ module Salopulse
4
+ module Error
5
+ class InvalidDSN < Base; end
6
+ end
7
+ end
@@ -0,0 +1,67 @@
1
+ module Salopulse
2
+ class Flusher
3
+ def initialize(buffer:, transport:, interval:, batch_size:, logger:)
4
+ @buffer = buffer
5
+ @transport = transport
6
+ @interval = interval
7
+ @batch_size = batch_size
8
+ @logger = logger
9
+ @stop = false
10
+ @thread = nil
11
+ end
12
+
13
+ def start
14
+ return if @thread&.alive?
15
+ @stop = false
16
+ @thread = Thread.new do
17
+ Thread.current.name = "salopulse-flusher" if Thread.current.respond_to?(:name=)
18
+ loop do
19
+ break if @stop
20
+ begin
21
+ flush_once
22
+ rescue StandardError => e
23
+ @logger.error("[Salopulse] flusher error: #{e.class}: #{e.message}")
24
+ end
25
+ sleep_with_interrupt(@interval)
26
+ end
27
+ end
28
+ end
29
+
30
+ def flush_once
31
+ events = @buffer.drain(max: @batch_size)
32
+ return 0 if events.empty?
33
+ @transport.send_batch(events)
34
+ events.size
35
+ end
36
+
37
+ def flush_all(timeout: 5)
38
+ deadline = monotonic_now + timeout
39
+ total = 0
40
+ while !@buffer.empty? && monotonic_now < deadline
41
+ total += flush_once
42
+ end
43
+ total
44
+ end
45
+
46
+ def stop(timeout: 5)
47
+ @stop = true
48
+ flush_all(timeout: timeout)
49
+ @thread&.join(timeout)
50
+ end
51
+
52
+ private
53
+
54
+ def sleep_with_interrupt(seconds)
55
+ slept = 0.0
56
+ step = 0.2
57
+ while slept < seconds && !@stop
58
+ sleep step
59
+ slept += step
60
+ end
61
+ end
62
+
63
+ def monotonic_now
64
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,37 @@
1
+ module Salopulse
2
+ module Instrumentation
3
+ class ActiveRecordSubscriber
4
+ INTERNAL_NAMES = %w[SCHEMA TRANSACTION].freeze
5
+
6
+ def self.subscribe(client)
7
+ require "active_support/notifications"
8
+ @subscriber ||= ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
9
+ event = ActiveSupport::Notifications::Event.new(*args)
10
+ next if internal?(event)
11
+ next if Salopulse::RequestContext.suppressed?
12
+
13
+ client.capture_sql(
14
+ query: event.payload[:sql],
15
+ duration_ms: event.duration,
16
+ rows_returned: event.payload[:row_count]
17
+ )
18
+ end
19
+ end
20
+
21
+ def self.unsubscribe
22
+ return unless @subscriber
23
+ require "active_support/notifications"
24
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
25
+ @subscriber = nil
26
+ end
27
+
28
+ def self.internal?(event)
29
+ name = event.payload[:name].to_s
30
+ return true if INTERNAL_NAMES.include?(name)
31
+ return true if event.payload[:cached]
32
+ sql = event.payload[:sql].to_s
33
+ sql.start_with?("SHOW ", "EXPLAIN ", "PRAGMA ")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,69 @@
1
+ require_relative "../request_context"
2
+ require_relative "../sanitizer"
3
+
4
+ module Salopulse
5
+ module Instrumentation
6
+ class RackMiddleware
7
+ def initialize(app, client = Salopulse::Client.instance)
8
+ @app = app
9
+ @client = client
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) if @client.disabled?
14
+
15
+ endpoint = derive_endpoint(env)
16
+ http_method = env["REQUEST_METHOD"]
17
+ Salopulse::RequestContext.start(endpoint: endpoint, http_method: http_method)
18
+
19
+ status = 500
20
+ begin
21
+ status, headers, body = @app.call(env)
22
+ [status, headers, body]
23
+ rescue Exception => e # rubocop:disable Lint/RescueException
24
+ @client.capture_exception(e, environment_data: env_snapshot(env))
25
+ raise
26
+ ensure
27
+ begin
28
+ @client.capture_performance(
29
+ endpoint: endpoint,
30
+ http_method: http_method,
31
+ duration_ms: Salopulse::RequestContext.elapsed_ms,
32
+ status_code: status
33
+ )
34
+ @client.flush_request_scope_events
35
+ rescue StandardError
36
+ # never let SDK errors propagate
37
+ end
38
+ Salopulse::RequestContext.clear
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def derive_endpoint(env)
45
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
46
+ begin
47
+ route = Rails.application.routes.recognize_path(env["PATH_INFO"], method: env["REQUEST_METHOD"])
48
+ return "#{route[:controller]}##{route[:action]}" if route
49
+ rescue StandardError
50
+ end
51
+ end
52
+ env["PATH_INFO"]
53
+ end
54
+
55
+ def env_snapshot(env)
56
+ params = env["action_dispatch.request.parameters"] || env["rack.request.form_hash"] || {}
57
+ headers = env.each_with_object({}) do |(k, v), acc|
58
+ next unless k.is_a?(String) && k.start_with?("HTTP_")
59
+ acc[k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")] = v
60
+ end
61
+ {
62
+ "ip" => env["REMOTE_ADDR"],
63
+ "params" => Salopulse::Sanitizer.scrub_hash(params),
64
+ "headers" => Salopulse::Sanitizer.scrub_headers(headers)
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ module Salopulse
2
+ module LocalFingerprint
3
+ module_function
4
+
5
+ def for(sql)
6
+ sql.to_s
7
+ .downcase
8
+ .gsub(/'[^']*'/, "?")
9
+ .gsub(/\b\d+(\.\d+)?\b/, "?")
10
+ .gsub(/\bin\s*\([^)]+\)/, "in (?)")
11
+ .gsub(/\s+/, " ")
12
+ .strip
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ module Salopulse
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "salopulse.middleware" do |app|
4
+ app.middleware.use(Salopulse::Instrumentation::RackMiddleware)
5
+ end
6
+
7
+ initializer "salopulse.active_record" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ Salopulse::Instrumentation::ActiveRecordSubscriber.subscribe(Salopulse::Client.instance)
10
+ end
11
+ end
12
+
13
+ config.after_initialize do
14
+ client = Salopulse::Client.instance
15
+ if client.uninitialized? && ENV["SALOPULSE_DSN"]
16
+ Salopulse.init(dsn: ENV["SALOPULSE_DSN"], release: ENV["GIT_SHA"], environment: defined?(Rails) ? Rails.env : nil)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ require "securerandom"
2
+
3
+ module Salopulse
4
+ module RequestContext
5
+ KEY = :salopulse_request_context
6
+ SUPPRESS_KEY = :salopulse_suppressed
7
+
8
+ module_function
9
+
10
+ def start(endpoint:, http_method:)
11
+ Thread.current[KEY] = {
12
+ request_id: SecureRandom.uuid,
13
+ endpoint: endpoint,
14
+ http_method: http_method,
15
+ sql_events: [],
16
+ sql_fingerprint_counts: Hash.new(0),
17
+ started_at: monotonic_now,
18
+ user: nil,
19
+ tags: {},
20
+ suppressed: false
21
+ }
22
+ end
23
+
24
+ def current
25
+ Thread.current[KEY]
26
+ end
27
+
28
+ def active?
29
+ !current.nil?
30
+ end
31
+
32
+ def clear
33
+ Thread.current[KEY] = nil
34
+ end
35
+
36
+ def set_user(attrs)
37
+ current[:user] = attrs if current
38
+ end
39
+
40
+ def set_tag(key, value)
41
+ current[:tags][key] = value if current
42
+ end
43
+
44
+ def record_sql_event(event, fingerprint)
45
+ ctx = current
46
+ return false unless ctx
47
+ ctx[:sql_events] << [event, fingerprint]
48
+ ctx[:sql_fingerprint_counts][fingerprint] += 1
49
+ true
50
+ end
51
+
52
+ def elapsed_ms
53
+ return 0 unless current
54
+ ((monotonic_now - current[:started_at]) * 1000).to_i
55
+ end
56
+
57
+ def suppressed?
58
+ Thread.current[SUPPRESS_KEY] == true
59
+ end
60
+
61
+ def with_suppression
62
+ previous_ctx = Thread.current[KEY]
63
+ previous_suppress = Thread.current[SUPPRESS_KEY]
64
+ Thread.current[KEY] = nil
65
+ Thread.current[SUPPRESS_KEY] = true
66
+ yield
67
+ ensure
68
+ Thread.current[KEY] = previous_ctx
69
+ Thread.current[SUPPRESS_KEY] = previous_suppress
70
+ end
71
+
72
+ def monotonic_now
73
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,41 @@
1
+ module Salopulse
2
+ module Sanitizer
3
+ SENSITIVE_KEYS = %w[
4
+ password password_confirmation token api_key secret
5
+ access_token refresh_token authorization cookie
6
+ credit_card card_number cvv ssn
7
+ ].freeze
8
+
9
+ SENSITIVE_HEADERS = %w[
10
+ authorization cookie x-api-key x-auth-token
11
+ ].freeze
12
+
13
+ FILTERED = "[FILTERED]".freeze
14
+
15
+ module_function
16
+
17
+ def scrub_hash(value)
18
+ case value
19
+ when Hash
20
+ value.each_with_object({}) do |(k, v), acc|
21
+ acc[k] = sensitive_key?(k) ? FILTERED : scrub_hash(v)
22
+ end
23
+ when Array
24
+ value.map { |v| scrub_hash(v) }
25
+ else
26
+ value
27
+ end
28
+ end
29
+
30
+ def scrub_headers(headers)
31
+ return {} unless headers.respond_to?(:each_pair) || headers.is_a?(Hash)
32
+ headers.to_h.each_with_object({}) do |(k, v), acc|
33
+ acc[k] = SENSITIVE_HEADERS.include?(k.to_s.downcase) ? FILTERED : v
34
+ end
35
+ end
36
+
37
+ def sensitive_key?(key)
38
+ SENSITIVE_KEYS.include?(key.to_s.downcase)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,73 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require_relative "request_context"
5
+
6
+ module Salopulse
7
+ class Transport
8
+ MAX_RETRIES = 3
9
+ BACKOFF_BASE = 0.5
10
+
11
+ Response = Struct.new(:code, :body, keyword_init: true)
12
+
13
+ def initialize(dsn:, sdk_version:, logger:, open_timeout: 5, read_timeout: 10)
14
+ @dsn = dsn
15
+ @sdk_version = sdk_version
16
+ @logger = logger
17
+ @uri = URI.parse(dsn.ingest_url)
18
+ @open_timeout = open_timeout
19
+ @read_timeout = read_timeout
20
+ end
21
+
22
+ def send_batch(events)
23
+ return true if events.nil? || events.empty?
24
+
25
+ body = JSON.dump(events: events)
26
+
27
+ RequestContext.with_suppression do
28
+ attempt = 0
29
+ loop do
30
+ response = post(body)
31
+ code = response.code.to_i
32
+ return true if success?(code)
33
+ return false if non_retryable?(code)
34
+
35
+ attempt += 1
36
+ if attempt >= MAX_RETRIES
37
+ @logger.warn("[Salopulse] giving up after #{attempt} attempts, last code=#{code}")
38
+ return false
39
+ end
40
+ sleep(BACKOFF_BASE * (2**(attempt - 1)))
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def post(body)
48
+ http = Net::HTTP.new(@uri.host, @uri.port)
49
+ http.use_ssl = (@uri.scheme == "https")
50
+ http.open_timeout = @open_timeout
51
+ http.read_timeout = @read_timeout
52
+
53
+ request = Net::HTTP::Post.new(@uri.request_uri)
54
+ request["Content-Type"] = "application/json"
55
+ request["X-Api-Key"] = @dsn.api_key
56
+ request["User-Agent"] = "salopulse-ruby/#{@sdk_version}"
57
+ request.body = body
58
+
59
+ http.request(request)
60
+ rescue StandardError => e
61
+ @logger.warn("[Salopulse] transport error: #{e.class}: #{e.message}")
62
+ Response.new(code: "0", body: nil)
63
+ end
64
+
65
+ def success?(code)
66
+ code == 202
67
+ end
68
+
69
+ def non_retryable?(code)
70
+ code >= 400 && code < 500 && code != 429
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,3 @@
1
+ module Salopulse
2
+ VERSION = "0.2.0".freeze
3
+ end
data/lib/salopulse.rb CHANGED
@@ -1,18 +1,56 @@
1
- # frozen_string_literal: true
1
+ require_relative "salopulse/version"
2
+ require_relative "salopulse/error/base"
3
+ require_relative "salopulse/error/invalid_dsn"
4
+ require_relative "salopulse/configuration"
5
+ require_relative "salopulse/dsn"
6
+ require_relative "salopulse/buffer"
7
+ require_relative "salopulse/sanitizer"
8
+ require_relative "salopulse/local_fingerprint"
9
+ require_relative "salopulse/request_context"
10
+ require_relative "salopulse/transport"
11
+ require_relative "salopulse/flusher"
12
+ require_relative "salopulse/client"
13
+ require_relative "salopulse/instrumentation/active_record_subscriber"
14
+ require_relative "salopulse/instrumentation/rack_middleware"
2
15
 
3
- require "salopulse/version"
4
- require "salopulse/configuration"
5
- require "salopulse/client"
6
- require "salopulse/middleware"
7
-
8
- module SaloPulse
16
+ module Salopulse
9
17
  class << self
10
- def configure
11
- yield config
18
+ def init(**options)
19
+ Client.instance.init(options)
20
+ end
21
+
22
+ def capture_exception(error, **opts)
23
+ Client.instance.capture_exception(error, **opts)
24
+ end
25
+
26
+ def capture_message(message, level: :info)
27
+ Client.instance.capture_message(message, level: level)
28
+ end
29
+
30
+ def flush(timeout: 5)
31
+ Client.instance.flush(timeout: timeout)
32
+ end
33
+
34
+ def close
35
+ Client.instance.close
36
+ end
37
+
38
+ def set_user(attrs)
39
+ Client.instance.set_user(attrs)
40
+ end
41
+
42
+ def set_tag(key, value)
43
+ Client.instance.set_tag(key, value)
44
+ end
45
+
46
+ def disabled?
47
+ Client.instance.disabled?
12
48
  end
13
49
 
14
- def config
15
- @config ||= Configuration.new
50
+ def configuration
51
+ Client.instance.configuration
16
52
  end
17
53
  end
18
54
  end
55
+
56
+ require_relative "salopulse/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: salopulse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Salih İmran Büker
@@ -10,52 +10,98 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: json
13
+ name: rspec
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
18
+ version: '3.12'
19
+ type: :development
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: webmock
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.18'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.18'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
24
52
  - !ruby/object:Gem::Version
25
- version: '0'
53
+ version: '13.0'
26
54
  - !ruby/object:Gem::Dependency
27
- name: net-http
55
+ name: activesupport
28
56
  requirement: !ruby/object:Gem::Requirement
29
57
  requirements:
30
58
  - - ">="
31
59
  - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
60
+ version: '7.0'
61
+ type: :development
34
62
  prerelease: false
35
63
  version_requirements: !ruby/object:Gem::Requirement
36
64
  requirements:
37
65
  - - ">="
38
66
  - !ruby/object:Gem::Version
39
- version: '0'
40
- description: SaloPulse Ruby SDK provides automatic request tracking, performance measurement,
41
- and real-time analytics integration for Rails apps. It captures HTTP traffic and
42
- sends metrics to the SaloPulse Observability Platform.
67
+ version: '7.0'
68
+ description: Automatic SQL, error, and HTTP performance telemetry for Ruby applications.
69
+ Captures ActiveRecord queries, exceptions, and Rack request performance with zero-config
70
+ setup for Rails.
43
71
  email:
44
- - info@salopulse.com
72
+ - salihimranbuker44@gmail.com
45
73
  executables: []
46
74
  extensions: []
47
75
  extra_rdoc_files: []
48
76
  files:
77
+ - CHANGELOG.md
49
78
  - LICENSE
50
79
  - README.md
51
80
  - lib/salopulse.rb
81
+ - lib/salopulse/buffer.rb
52
82
  - lib/salopulse/client.rb
53
83
  - lib/salopulse/configuration.rb
54
- - lib/salopulse/middleware.rb
55
- homepage: https://github.com/mersies/salopulse-sdk-ruby
84
+ - lib/salopulse/dsn.rb
85
+ - lib/salopulse/error/base.rb
86
+ - lib/salopulse/error/invalid_dsn.rb
87
+ - lib/salopulse/flusher.rb
88
+ - lib/salopulse/instrumentation/active_record_subscriber.rb
89
+ - lib/salopulse/instrumentation/rack_middleware.rb
90
+ - lib/salopulse/local_fingerprint.rb
91
+ - lib/salopulse/railtie.rb
92
+ - lib/salopulse/request_context.rb
93
+ - lib/salopulse/sanitizer.rb
94
+ - lib/salopulse/transport.rb
95
+ - lib/salopulse/version.rb
96
+ homepage: https://github.com/mersieS/salopulse-ruby-sdk
56
97
  licenses:
57
98
  - MIT
58
- metadata: {}
99
+ metadata:
100
+ homepage_uri: https://github.com/mersieS/salopulse-ruby-sdk
101
+ source_code_uri: https://github.com/mersieS/salopulse-ruby-sdk
102
+ changelog_uri: https://github.com/mersieS/salopulse-ruby-sdk/blob/main/CHANGELOG.md
103
+ bug_tracker_uri: https://github.com/mersieS/salopulse-ruby-sdk/issues
104
+ rubygems_mfa_required: 'true'
59
105
  rdoc_options: []
60
106
  require_paths:
61
107
  - lib
@@ -63,7 +109,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
63
109
  requirements:
64
110
  - - ">="
65
111
  - !ruby/object:Gem::Version
66
- version: '2.7'
112
+ version: '3.0'
67
113
  required_rubygems_version: !ruby/object:Gem::Requirement
68
114
  requirements:
69
115
  - - ">="
@@ -72,6 +118,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
118
  requirements: []
73
119
  rubygems_version: 3.7.2
74
120
  specification_version: 4
75
- summary: SaloPulse Ruby SDK Lightweight monitoring and request analytics for Rails
76
- applications.
121
+ summary: Ruby SDK for Salopulse APM platform
77
122
  test_files: []
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rack'
4
- require 'time'
5
-
6
- module SaloPulse
7
- class Middleware
8
- def initialize(app)
9
- @app = app
10
- end
11
-
12
- def call(env)
13
- start_monotonic = monotonic_time
14
-
15
- status, headers, body = @app.call(env)
16
-
17
- duration_ms = ((monotonic_time - start_monotonic) * 1000.0).round(2)
18
-
19
- begin
20
- event = build_event(env, status, duration_ms)
21
- SaloPulse::Client.send_event(event) if event
22
- rescue StandardError => e
23
- log_middleware_error(e)
24
- end
25
-
26
- [status, headers, body]
27
- end
28
-
29
- private
30
-
31
- def monotonic_time
32
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
- end
34
-
35
- def build_event(env, status, duration_ms)
36
- req = Rack::Request.new(env)
37
-
38
- {
39
- path: req.path,
40
- method: req.request_method,
41
- status: status,
42
- duration_ms: duration_ms,
43
- ip: extract_ip(env, req),
44
- user_agent: env['HTTP_USER_AGENT'],
45
- occurred_at: Time.now.utc.iso8601
46
- }
47
- end
48
-
49
- def extract_ip(env, req)
50
- if env['action_dispatch.remote_ip']
51
- env['action_dispatch.remote_ip'].to_s
52
- elsif req.respond_to?(:ip)
53
- req.ip
54
- else
55
- env['REMOTE_ADDR']
56
- end
57
- end
58
-
59
- def log_middleware_error(error)
60
- message = "[SaloPulse] Middleware error: #{error.class}: #{error.message}"
61
-
62
- if defined?(Rails)
63
- Rails.logger.debug(message)
64
- else
65
- warn(message)
66
- end
67
- end
68
- end
69
- end