salopulse 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/LICENSE +8 -21
- data/README.md +72 -16
- data/lib/salopulse/buffer.rb +56 -0
- data/lib/salopulse/client.rb +232 -33
- data/lib/salopulse/configuration.rb +15 -12
- data/lib/salopulse/dsn.rb +28 -0
- data/lib/salopulse/error/base.rb +5 -0
- data/lib/salopulse/error/invalid_dsn.rb +7 -0
- data/lib/salopulse/flusher.rb +67 -0
- data/lib/salopulse/instrumentation/active_record_subscriber.rb +37 -0
- data/lib/salopulse/instrumentation/rack_middleware.rb +69 -0
- data/lib/salopulse/local_fingerprint.rb +15 -0
- data/lib/salopulse/railtie.rb +20 -0
- data/lib/salopulse/request_context.rb +76 -0
- data/lib/salopulse/sanitizer.rb +41 -0
- data/lib/salopulse/transport.rb +73 -0
- data/lib/salopulse/version.rb +3 -0
- data/lib/salopulse.rb +49 -11
- metadata +66 -21
- data/lib/salopulse/middleware.rb +0 -69
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0deb38c69a0976dd9139e0014a6b1af52635fa38c526a31f5fe8ab7ad3ec37ac
|
|
4
|
+
data.tar.gz: f9ec686f732520929bca77433c82fc11022aba9fecc22728e7f2ef849b843ed1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6d189bf5387cee4601ef5946ee5a74cd51355682184e7fb9f63aa8abcf4b8ef606072e9bfd38e7bf676c6010dba466c0c23b84f07c6807cea34da5d8c1864a4
|
|
7
|
+
data.tar.gz: 56e41da52bd09d63b852aec442b2ab148d2695c4c916b4d9a40ce462cfc4b4a739649adc36b9dc519c01d1c0d1d8c7c5899282497c48ce2cfd079e660a84c2d7
|
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.1] - 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)
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1
|
+
# Salopulse Ruby SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Otomatik SQL, hata ve HTTP performans telemetrisi — Ruby uygulamaları için Salopulse APM istemcisi.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Kurulum
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "salopulse"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
bundle install
|
|
14
|
+
```
|
|
8
15
|
|
|
9
|
-
##
|
|
16
|
+
## Hızlı Başlangıç
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# config/initializers/salopulse.rb
|
|
20
|
+
Salopulse.init(dsn: ENV["SALOPULSE_DSN"])
|
|
21
|
+
```
|
|
10
22
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
## Manuel API
|
|
24
32
|
|
|
25
33
|
```ruby
|
|
26
|
-
|
|
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
|
data/lib/salopulse/client.rb
CHANGED
|
@@ -1,52 +1,251 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
require 'uri'
|
|
5
|
-
require 'json'
|
|
6
|
-
|
|
7
|
-
module SaloPulse
|
|
13
|
+
module Salopulse
|
|
8
14
|
class Client
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
include Singleton
|
|
16
|
+
|
|
17
|
+
attr_reader :configuration, :buffer, :transport, :flusher, :dsn
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
def initialize
|
|
20
|
+
@initialized = false
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
def init(options = {})
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
return self if @initialized
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
request['Content-Type'] = 'application/json'
|
|
24
|
-
request['Accept'] = 'application/json'
|
|
31
|
+
return self unless @configuration.enabled
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
68
|
+
# --- Capture API ---------------------------------------------------------
|
|
32
69
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
153
|
+
def flush_request_scope_events
|
|
154
|
+
ctx = RequestContext.current
|
|
155
|
+
return unless ctx
|
|
40
156
|
|
|
41
|
-
|
|
42
|
-
|
|
157
|
+
threshold = @configuration.n1_threshold
|
|
158
|
+
counts = ctx[:sql_fingerprint_counts]
|
|
43
159
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
1
|
+
require "logger"
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Salopulse
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :
|
|
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
|
-
@
|
|
9
|
-
@environment
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
@
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@enabled
|
|
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 == "http"
|
|
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 = "http://#{uri.host}#{port_part}/api/v1/ingest"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
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,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
|
data/lib/salopulse.rb
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
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
|
|
4
|
+
version: 0.2.1
|
|
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:
|
|
13
|
+
name: rspec
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
19
|
-
type: :
|
|
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:
|
|
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: :
|
|
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:
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
-
|
|
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/
|
|
55
|
-
|
|
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: '
|
|
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:
|
|
76
|
-
applications.
|
|
121
|
+
summary: Ruby SDK for Salopulse APM platform
|
|
77
122
|
test_files: []
|
data/lib/salopulse/middleware.rb
DELETED
|
@@ -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
|