logbrew-sdk 0.1.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 +7 -0
- data/README.md +187 -0
- data/examples/Makefile +14 -0
- data/examples/readme_example.rb +65 -0
- data/examples/real_user_smoke.rb +45 -0
- data/lib/logbrew.rb +936 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8171c73805b0809b5be561b9e315e64db5e49dc7bcc67df243a35e675bda9d36
|
|
4
|
+
data.tar.gz: e6884f90cbcc65af3873aed8aa7f2c5fa1ba3a53031d8c4ac4c3632fd2fc5a3d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2418916010dcadc2b7df1f584111f1e8cc9aa293b74a1eb905c12b74848a95fa092e681ee6e6d30efc237992192fc21b0463abd7301ab23df12382f10b048f21
|
|
7
|
+
data.tar.gz: 2e4a4fba1bd44796895e88394bc6e12da80370a1946b36e36b1c97eb87a5ebd1db39a30d9ef55414ffecca282b9f4f6eb1ac7338b5bb236e358004aca11ce6e3
|
data/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# LogBrew Ruby SDK
|
|
2
|
+
|
|
3
|
+
Public Ruby SDK for building, validating, previewing, and flushing LogBrew event batches, with standard-library `Net::HTTP` delivery, opt-in standard-library `Logger` support, Rack-compatible middleware, and a Rails error subscriber for Rails apps.
|
|
4
|
+
|
|
5
|
+
The package uses only Ruby standard-library features at runtime. The repository checks build the gem, inspect the artifact, install it into a fresh gem home, run shipped examples, and exercise HTTP delivery plus failure/lifecycle paths like a real user.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install logbrew-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local testing from this repository:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bash scripts/check_ruby_package.sh
|
|
17
|
+
bash scripts/real_user_ruby_smoke.sh
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "logbrew"
|
|
24
|
+
|
|
25
|
+
client = LogBrew::Client.create(
|
|
26
|
+
api_key: "LOGBREW_API_KEY",
|
|
27
|
+
sdk_name: "my-ruby-app",
|
|
28
|
+
sdk_version: "1.0.0"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
client.release(
|
|
32
|
+
"evt_release_001",
|
|
33
|
+
"2026-06-02T10:00:00Z",
|
|
34
|
+
version: "1.2.3",
|
|
35
|
+
commit: "abc123def456"
|
|
36
|
+
)
|
|
37
|
+
client.action(
|
|
38
|
+
"evt_action_001",
|
|
39
|
+
"2026-06-02T10:00:05Z",
|
|
40
|
+
name: "deploy",
|
|
41
|
+
status: "success"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
puts client.preview_json
|
|
45
|
+
response = client.shutdown(LogBrew::RecordingTransport.always_accept)
|
|
46
|
+
warn response.status_code
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## HTTP Delivery
|
|
50
|
+
|
|
51
|
+
Use `LogBrew::HttpTransport` when you want the SDK to POST queued batches to LogBrew:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require "logbrew"
|
|
55
|
+
|
|
56
|
+
client = LogBrew::Client.create(
|
|
57
|
+
api_key: "LOGBREW_API_KEY",
|
|
58
|
+
sdk_name: "my-ruby-app",
|
|
59
|
+
sdk_version: "1.0.0"
|
|
60
|
+
)
|
|
61
|
+
client.log("evt_log_001", "2026-06-02T10:00:03Z", message: "worker started", level: "info")
|
|
62
|
+
|
|
63
|
+
transport = LogBrew::HttpTransport.new(
|
|
64
|
+
endpoint: LogBrew::HttpTransport::DEFAULT_ENDPOINT,
|
|
65
|
+
headers: { "x-logbrew-source" => "ruby-worker" },
|
|
66
|
+
timeout: 10
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
response = client.shutdown(transport)
|
|
70
|
+
warn response.status_code
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`HttpTransport` sends JSON with the SDK key in the `authorization` header, supports a custom endpoint, headers, timeout, and app-owned HTTP client object, maps HTTP statuses through the client's retry rules, and converts request/time-out failures into retryable transport errors.
|
|
74
|
+
|
|
75
|
+
## Examples
|
|
76
|
+
|
|
77
|
+
From `ruby/logbrew-ruby`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cd examples && make
|
|
81
|
+
cd examples && make run-readme-example
|
|
82
|
+
cd examples && make run
|
|
83
|
+
cd examples && make run-real-user-smoke
|
|
84
|
+
ruby examples/readme_example.rb
|
|
85
|
+
ruby examples/real_user_smoke.rb
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`make run` is the shorter alias for the stronger real-user smoke example.
|
|
89
|
+
|
|
90
|
+
## Standard Logger
|
|
91
|
+
|
|
92
|
+
`LogBrew::Logger` subclasses Ruby's standard `::Logger`, so existing Ruby logging calls can queue LogBrew log events without adding a runtime dependency.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require "logbrew"
|
|
96
|
+
|
|
97
|
+
client = LogBrew::Client.create(
|
|
98
|
+
api_key: "LOGBREW_API_KEY",
|
|
99
|
+
sdk_name: "my-ruby-app",
|
|
100
|
+
sdk_version: "1.0.0"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
logger = LogBrew::Logger.new(
|
|
104
|
+
client: client,
|
|
105
|
+
logger_name: "checkout",
|
|
106
|
+
progname: "checkout",
|
|
107
|
+
metadata: { service: "web" }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.warn("checkout slow")
|
|
111
|
+
logger.error(RuntimeError.new("payment failed"))
|
|
112
|
+
|
|
113
|
+
client.flush(LogBrew::RecordingTransport.always_accept)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The adapter respects Ruby logger levels and lazy block messages, maps `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`, and `UNKNOWN` to LogBrew log levels, captures `progname`, primitive base metadata, and exception type/message, and omits exception backtrace text unless `include_exception_backtrace: true` is set. Logs queue by default; pass `transport:` plus `flush_on_log: true` or call `flush_logbrew` for immediate delivery.
|
|
117
|
+
|
|
118
|
+
## Rack And Rails Middleware
|
|
119
|
+
|
|
120
|
+
Use `LogBrew::RackMiddleware` when a Rails, Sinatra, or Rack app should capture request spans and unhandled app exceptions without adding a framework dependency to the SDK.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
require "logbrew"
|
|
124
|
+
|
|
125
|
+
client = LogBrew::Client.create(
|
|
126
|
+
api_key: "LOGBREW_API_KEY",
|
|
127
|
+
sdk_name: "my-rails-app",
|
|
128
|
+
sdk_version: "1.0.0"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Rails: config/application.rb
|
|
132
|
+
config.middleware.use(
|
|
133
|
+
LogBrew::RackMiddleware,
|
|
134
|
+
client: client,
|
|
135
|
+
transport: LogBrew::HttpTransport.new,
|
|
136
|
+
flush_on_response: true,
|
|
137
|
+
metadata: { service: "web" }
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
For plain Rack apps, wrap the app directly:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
app = LogBrew::RackMiddleware.new(
|
|
145
|
+
->(_env) { [200, { "content-type" => "text/plain" }, ["ok"]] },
|
|
146
|
+
client: client
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The middleware records successful responses as span events, records unhandled app exceptions as issue plus error-span events, and re-raises app exceptions so Rails or Rack keeps normal response handling. It captures method, path without query text, status code, request id when present, primitive base metadata, exception type/message, and duration. Exception backtrace text is omitted unless `include_exception_backtrace: true` is set. Events queue by default; pass `transport:` plus `flush_on_response: true` when each response should flush.
|
|
151
|
+
|
|
152
|
+
## Rails Error Subscriber
|
|
153
|
+
|
|
154
|
+
Use `LogBrew::RailsErrorSubscriber` when handled or manually reported Rails errors should queue LogBrew issue events through Rails' own error reporter.
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
require "logbrew"
|
|
158
|
+
|
|
159
|
+
client = LogBrew::Client.create(
|
|
160
|
+
api_key: "LOGBREW_API_KEY",
|
|
161
|
+
sdk_name: "my-rails-app",
|
|
162
|
+
sdk_version: "1.0.0"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Rails: config/initializers/logbrew.rb
|
|
166
|
+
Rails.error.subscribe(
|
|
167
|
+
LogBrew::RailsErrorSubscriber.new(
|
|
168
|
+
client: client,
|
|
169
|
+
transport: LogBrew::HttpTransport.new,
|
|
170
|
+
flush_on_report: true,
|
|
171
|
+
metadata: { service: "web" }
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The subscriber implements `report(error, handled:, severity:, context:, source:, **options)`, captures handled state, severity, Rails source, primitive context values, primitive base metadata, and exception type/message, and omits exception backtrace text unless `include_exception_backtrace: true` is set. It queues by default; pass `transport:` plus `flush_on_report: true` when each report should flush. If you also use `LogBrew::RackMiddleware`, keep the subscriber focused on handled/manual reports so unhandled request exceptions are not captured twice.
|
|
177
|
+
|
|
178
|
+
## Behavior
|
|
179
|
+
|
|
180
|
+
- `preview_json` returns the queued batch as pretty JSON.
|
|
181
|
+
- `flush(transport)` sends queued events, retries retryable failures, and clears the queue only after a 2xx response.
|
|
182
|
+
- `LogBrew::HttpTransport` sends queued batches through Ruby's standard `Net::HTTP` with configurable endpoint, headers, timeout, and app-owned HTTP client support.
|
|
183
|
+
- `LogBrew::RackMiddleware` captures Rack request spans and unhandled app exceptions without requiring Rails or Rack at runtime.
|
|
184
|
+
- `LogBrew::RailsErrorSubscriber` captures handled/manual Rails error reports without requiring Rails at runtime.
|
|
185
|
+
- `shutdown(transport)` flushes queued events and rejects later writes.
|
|
186
|
+
- `LogBrew::RecordingTransport.always_accept` is useful for local examples and tests.
|
|
187
|
+
- `LogBrew::SdkError` exposes stable `code` and `message` values for user-facing failure handling.
|
data/examples/Makefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.PHONY: help run run-readme-example run-real-user-smoke
|
|
2
|
+
|
|
3
|
+
help:
|
|
4
|
+
@printf '%s\n' 'run-readme-example -> make run-readme-example'
|
|
5
|
+
@printf '%s\n' 'run (real-user-smoke) -> make run'
|
|
6
|
+
@printf '%s\n' 'run-real-user-smoke -> make run-real-user-smoke'
|
|
7
|
+
|
|
8
|
+
run: run-real-user-smoke
|
|
9
|
+
|
|
10
|
+
run-readme-example:
|
|
11
|
+
@ruby readme_example.rb
|
|
12
|
+
|
|
13
|
+
run-real-user-smoke:
|
|
14
|
+
@ruby real_user_smoke.rb
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "logbrew"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../lib/logbrew"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def enqueue_all(client)
|
|
10
|
+
client.release(
|
|
11
|
+
"evt_release_001",
|
|
12
|
+
"2026-06-02T10:00:00Z",
|
|
13
|
+
version: "1.2.3",
|
|
14
|
+
commit: "abc123def456",
|
|
15
|
+
notes: "Public release marker"
|
|
16
|
+
)
|
|
17
|
+
client.environment(
|
|
18
|
+
"evt_environment_001",
|
|
19
|
+
"2026-06-02T10:00:01Z",
|
|
20
|
+
name: "production",
|
|
21
|
+
region: "global"
|
|
22
|
+
)
|
|
23
|
+
client.issue(
|
|
24
|
+
"evt_issue_001",
|
|
25
|
+
"2026-06-02T10:00:02Z",
|
|
26
|
+
title: "Checkout timeout",
|
|
27
|
+
level: "error",
|
|
28
|
+
message: "Request timed out after retry budget"
|
|
29
|
+
)
|
|
30
|
+
client.log(
|
|
31
|
+
"evt_log_001",
|
|
32
|
+
"2026-06-02T10:00:03Z",
|
|
33
|
+
message: "worker started",
|
|
34
|
+
level: "info",
|
|
35
|
+
logger: "job-runner"
|
|
36
|
+
)
|
|
37
|
+
client.span(
|
|
38
|
+
"evt_span_001",
|
|
39
|
+
"2026-06-02T10:00:04Z",
|
|
40
|
+
name: "GET /health",
|
|
41
|
+
traceId: "trace_001",
|
|
42
|
+
spanId: "span_001",
|
|
43
|
+
status: "ok",
|
|
44
|
+
durationMs: 12.5
|
|
45
|
+
)
|
|
46
|
+
client.action(
|
|
47
|
+
"evt_action_001",
|
|
48
|
+
"2026-06-02T10:00:05Z",
|
|
49
|
+
name: "deploy",
|
|
50
|
+
status: "success"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if __FILE__ == $PROGRAM_NAME
|
|
55
|
+
client = LogBrew::Client.create(
|
|
56
|
+
api_key: "LOGBREW_API_KEY",
|
|
57
|
+
sdk_name: "logbrew-ruby",
|
|
58
|
+
sdk_version: "0.1.0"
|
|
59
|
+
)
|
|
60
|
+
enqueue_all(client)
|
|
61
|
+
|
|
62
|
+
puts client.preview_json
|
|
63
|
+
response = client.shutdown(LogBrew::RecordingTransport.always_accept)
|
|
64
|
+
$stderr.puts JSON.generate(ok: true, status: response.status_code, attempts: response.attempts, events: 6)
|
|
65
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "logbrew"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
require_relative "../lib/logbrew"
|
|
9
|
+
end
|
|
10
|
+
require_relative "readme_example"
|
|
11
|
+
|
|
12
|
+
client = LogBrew::Client.create(
|
|
13
|
+
api_key: "LOGBREW_API_KEY",
|
|
14
|
+
sdk_name: "logbrew-ruby",
|
|
15
|
+
sdk_version: "0.1.0"
|
|
16
|
+
)
|
|
17
|
+
enqueue_all(client)
|
|
18
|
+
|
|
19
|
+
puts client.preview_json
|
|
20
|
+
response = client.shutdown(LogBrew::RecordingTransport.always_accept)
|
|
21
|
+
|
|
22
|
+
retry_client = LogBrew::Client.create(
|
|
23
|
+
api_key: "LOGBREW_API_KEY",
|
|
24
|
+
sdk_name: "logbrew-ruby",
|
|
25
|
+
sdk_version: "0.1.0"
|
|
26
|
+
)
|
|
27
|
+
enqueue_all(retry_client)
|
|
28
|
+
retry_response = retry_client.flush(
|
|
29
|
+
LogBrew::RecordingTransport.new([LogBrew::TransportError.network("temporary outage"), 202])
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
rejected_after_shutdown = false
|
|
33
|
+
begin
|
|
34
|
+
client.action("evt_action_002", "2026-06-02T10:00:06Z", name: "deploy", status: "success")
|
|
35
|
+
rescue LogBrew::SdkError => error
|
|
36
|
+
rejected_after_shutdown = error.code == "shutdown_error"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
$stderr.puts JSON.generate(
|
|
40
|
+
ok: rejected_after_shutdown,
|
|
41
|
+
status: response.status_code,
|
|
42
|
+
attempts: response.attempts,
|
|
43
|
+
retryAttempts: retry_response.attempts,
|
|
44
|
+
events: 6
|
|
45
|
+
)
|
data/lib/logbrew.rb
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "timeout"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module LogBrew
|
|
12
|
+
ISSUE_LEVELS = %w[info warning error critical].freeze
|
|
13
|
+
LOG_LEVELS = %w[debug info warning error].freeze
|
|
14
|
+
SPAN_STATUSES = %w[ok error].freeze
|
|
15
|
+
ACTION_STATUSES = %w[queued running success failure].freeze
|
|
16
|
+
|
|
17
|
+
class SdkError < StandardError
|
|
18
|
+
attr_reader :code
|
|
19
|
+
|
|
20
|
+
def initialize(code, message)
|
|
21
|
+
@code = code
|
|
22
|
+
super("#{code}: #{message}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class TransportError < StandardError
|
|
27
|
+
attr_reader :code, :retryable
|
|
28
|
+
|
|
29
|
+
def initialize(code, message, retryable: false)
|
|
30
|
+
@code = code
|
|
31
|
+
@retryable = retryable
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.network(message)
|
|
36
|
+
new("network_failure", message, retryable: true)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class TransportResponse
|
|
41
|
+
attr_reader :status_code, :attempts
|
|
42
|
+
|
|
43
|
+
def initialize(status_code, attempts)
|
|
44
|
+
@status_code = status_code
|
|
45
|
+
@attempts = attempts
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class RecordingTransport
|
|
50
|
+
attr_reader :sent_bodies
|
|
51
|
+
|
|
52
|
+
def initialize(scripted_responses = [202])
|
|
53
|
+
@scripted_responses = scripted_responses.empty? ? [202] : scripted_responses.dup
|
|
54
|
+
@sent_bodies = []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.always_accept
|
|
58
|
+
new([202])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def last_body
|
|
62
|
+
@sent_bodies[-1]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send(api_key, body)
|
|
66
|
+
Validation.require_non_empty("api_key", api_key)
|
|
67
|
+
@sent_bodies << body
|
|
68
|
+
|
|
69
|
+
response = @scripted_responses.empty? ? 202 : @scripted_responses.shift
|
|
70
|
+
raise response if response.is_a?(TransportError)
|
|
71
|
+
raise response if response.is_a?(SdkError)
|
|
72
|
+
|
|
73
|
+
status_code = response.is_a?(TransportResponse) ? response.status_code : response.to_i
|
|
74
|
+
TransportResponse.new(status_code.zero? ? 202 : status_code, 1)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class HttpTransport
|
|
79
|
+
DEFAULT_ENDPOINT = "https://api.logbrew.com/v1/events"
|
|
80
|
+
DEFAULT_TIMEOUT = 10
|
|
81
|
+
|
|
82
|
+
attr_reader :endpoint, :headers, :timeout, :http_client
|
|
83
|
+
|
|
84
|
+
def initialize(endpoint: DEFAULT_ENDPOINT, headers: {}, timeout: DEFAULT_TIMEOUT, http_client: nil)
|
|
85
|
+
@endpoint = validate_endpoint(endpoint)
|
|
86
|
+
@headers = copy_headers(headers)
|
|
87
|
+
@timeout = validate_timeout(timeout)
|
|
88
|
+
@http_client = http_client
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send(api_key, body)
|
|
92
|
+
Validation.require_non_empty("api_key", api_key)
|
|
93
|
+
raise SdkError.new("validation_error", "body must be non-empty") if body.nil?
|
|
94
|
+
|
|
95
|
+
request = Net::HTTP::Post.new(request_path)
|
|
96
|
+
request["authorization"] = "Bearer #{api_key}"
|
|
97
|
+
request["content-type"] = "application/json"
|
|
98
|
+
@headers.each { |name, value| request[name] = value }
|
|
99
|
+
request.body = body
|
|
100
|
+
|
|
101
|
+
response = @http_client ? @http_client.request(request) : request_with_default_client(request)
|
|
102
|
+
TransportResponse.new(response.code.to_i, 1)
|
|
103
|
+
rescue TransportError
|
|
104
|
+
raise
|
|
105
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, EOFError, Net::OpenTimeout, Net::ReadTimeout => error
|
|
106
|
+
raise TransportError.network("http transport failed: #{error.message}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def request_path
|
|
112
|
+
path = @endpoint.path.empty? ? "/" : @endpoint.path
|
|
113
|
+
return path if @endpoint.query.nil? || @endpoint.query.empty?
|
|
114
|
+
|
|
115
|
+
"#{path}?#{@endpoint.query}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def request_with_default_client(request)
|
|
119
|
+
http = Net::HTTP.new(@endpoint.host, @endpoint.port)
|
|
120
|
+
http.use_ssl = @endpoint.scheme == "https"
|
|
121
|
+
http.open_timeout = @timeout
|
|
122
|
+
http.read_timeout = @timeout
|
|
123
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
124
|
+
http.start { |client| client.request(request) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_endpoint(endpoint)
|
|
128
|
+
uri = endpoint.is_a?(URI) ? endpoint : URI.parse(endpoint.to_s)
|
|
129
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
130
|
+
raise SdkError.new("configuration_error", "HTTP transport endpoint must use http or https")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
uri
|
|
134
|
+
rescue URI::InvalidURIError => error
|
|
135
|
+
raise SdkError.new("configuration_error", "invalid HTTP transport endpoint: #{error.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def copy_headers(headers)
|
|
139
|
+
raise SdkError.new("configuration_error", "HTTP transport headers must be an object") unless headers.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
headers.each_with_object({}) do |(name, value), copied|
|
|
142
|
+
normalized_name = name.to_s
|
|
143
|
+
raise SdkError.new("configuration_error", "HTTP transport header name must be non-empty") if normalized_name.strip.empty?
|
|
144
|
+
raise SdkError.new("configuration_error", "HTTP transport header value must be non-null") if value.nil?
|
|
145
|
+
|
|
146
|
+
copied[normalized_name] = value.to_s
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_timeout(timeout)
|
|
151
|
+
value = timeout.to_f
|
|
152
|
+
raise SdkError.new("configuration_error", "HTTP transport timeout must be positive") unless value.positive? && value.finite?
|
|
153
|
+
|
|
154
|
+
value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class Logger < ::Logger
|
|
159
|
+
DEFAULT_LOGGER_NAME = "ruby-logger"
|
|
160
|
+
SEVERITY_TO_LOGBREW_LEVEL = {
|
|
161
|
+
::Logger::DEBUG => "debug",
|
|
162
|
+
::Logger::INFO => "info",
|
|
163
|
+
::Logger::WARN => "warning",
|
|
164
|
+
::Logger::ERROR => "error",
|
|
165
|
+
::Logger::FATAL => "error",
|
|
166
|
+
::Logger::UNKNOWN => "error"
|
|
167
|
+
}.freeze
|
|
168
|
+
|
|
169
|
+
def initialize(
|
|
170
|
+
client:,
|
|
171
|
+
logdev: File::NULL,
|
|
172
|
+
logger_name: nil,
|
|
173
|
+
event_id_prefix: "ruby_log",
|
|
174
|
+
metadata: nil,
|
|
175
|
+
transport: nil,
|
|
176
|
+
flush_on_log: false,
|
|
177
|
+
include_exception_backtrace: false,
|
|
178
|
+
timestamp_provider: nil,
|
|
179
|
+
on_error: nil,
|
|
180
|
+
raise_errors: false,
|
|
181
|
+
level: ::Logger::DEBUG,
|
|
182
|
+
progname: nil,
|
|
183
|
+
formatter: nil,
|
|
184
|
+
datetime_format: nil
|
|
185
|
+
)
|
|
186
|
+
Validation.require_non_empty("logger name", logger_name) unless logger_name.nil?
|
|
187
|
+
Validation.require_non_empty("event id prefix", event_id_prefix)
|
|
188
|
+
raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
@client = client
|
|
191
|
+
@logger_name = logger_name
|
|
192
|
+
@event_id_prefix = event_id_prefix
|
|
193
|
+
@metadata = metadata || {}
|
|
194
|
+
@transport = transport
|
|
195
|
+
@flush_on_log = flush_on_log
|
|
196
|
+
@include_exception_backtrace = include_exception_backtrace
|
|
197
|
+
@timestamp_provider = timestamp_provider
|
|
198
|
+
@on_error = on_error
|
|
199
|
+
@raise_errors = raise_errors
|
|
200
|
+
@next_event_number = 0
|
|
201
|
+
|
|
202
|
+
super(logdev || File::NULL)
|
|
203
|
+
self.level = level
|
|
204
|
+
self.progname = progname unless progname.nil?
|
|
205
|
+
self.formatter = formatter unless formatter.nil?
|
|
206
|
+
self.datetime_format = datetime_format unless datetime_format.nil?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def add(severity, message = nil, progname = nil)
|
|
210
|
+
severity = ::Logger::UNKNOWN if severity.nil?
|
|
211
|
+
return true if severity < level
|
|
212
|
+
|
|
213
|
+
resolved_message, resolved_progname = resolve_log_arguments(message, progname, block_given?) do
|
|
214
|
+
yield
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
capture_logbrew_event(severity, resolved_message, resolved_progname)
|
|
219
|
+
rescue StandardError => error
|
|
220
|
+
handle_logbrew_error(error)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
super(severity, resolved_message, resolved_progname)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def flush_logbrew(transport = @transport)
|
|
227
|
+
return nil if transport.nil? || @client.pending_events.zero?
|
|
228
|
+
|
|
229
|
+
@client.flush(transport)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def resolve_log_arguments(message, progname, has_block)
|
|
235
|
+
effective_progname = progname.nil? ? self.progname : progname
|
|
236
|
+
return [message, effective_progname] unless message.nil?
|
|
237
|
+
|
|
238
|
+
if has_block
|
|
239
|
+
[yield, effective_progname]
|
|
240
|
+
else
|
|
241
|
+
[effective_progname, self.progname]
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def capture_logbrew_event(severity, message, progname)
|
|
246
|
+
@next_event_number += 1
|
|
247
|
+
@client.log(
|
|
248
|
+
"#{@event_id_prefix}_#{@next_event_number}",
|
|
249
|
+
logbrew_timestamp,
|
|
250
|
+
message: logbrew_message(message),
|
|
251
|
+
level: logbrew_level(severity),
|
|
252
|
+
logger: event_logger_name(progname),
|
|
253
|
+
metadata: logbrew_metadata(severity, message, progname)
|
|
254
|
+
)
|
|
255
|
+
flush_logbrew if @flush_on_log
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def handle_logbrew_error(error)
|
|
259
|
+
@on_error.call(error) if @on_error.respond_to?(:call)
|
|
260
|
+
raise error if @raise_errors
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def logbrew_timestamp
|
|
264
|
+
timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
|
|
265
|
+
return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
|
|
266
|
+
|
|
267
|
+
timestamp.to_s
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def logbrew_message(message)
|
|
271
|
+
return message.message if message.is_a?(Exception)
|
|
272
|
+
return message if message.is_a?(String)
|
|
273
|
+
|
|
274
|
+
message.inspect
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def logbrew_level(severity)
|
|
278
|
+
SEVERITY_TO_LOGBREW_LEVEL.fetch(severity.to_i, severity.to_i >= ::Logger::WARN ? "error" : "info")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def event_logger_name(progname)
|
|
282
|
+
configured = @logger_name || progname || self.progname || DEFAULT_LOGGER_NAME
|
|
283
|
+
configured.to_s
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def logbrew_metadata(severity, message, progname)
|
|
287
|
+
copy_metadata(@metadata).tap do |metadata|
|
|
288
|
+
metadata["rubySeverity"] = severity_label(severity)
|
|
289
|
+
metadata["progname"] = progname.to_s if primitive_metadata_value?(progname) && !progname.to_s.empty?
|
|
290
|
+
add_exception_metadata(metadata, message) if message.is_a?(Exception)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def severity_label(severity)
|
|
295
|
+
::Logger::SEV_LABEL[severity.to_i] || "ANY"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def add_exception_metadata(metadata, exception)
|
|
299
|
+
metadata["exceptionType"] = exception.class.name
|
|
300
|
+
metadata["exceptionMessage"] = exception.message
|
|
301
|
+
metadata["exceptionBacktrace"] = exception.backtrace.join("\n") if @include_exception_backtrace && exception.backtrace
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def copy_metadata(metadata)
|
|
305
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
306
|
+
copied[key.to_s] = value if primitive_metadata_value?(value)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def primitive_metadata_value?(value)
|
|
311
|
+
return true if value.nil? || value == true || value == false
|
|
312
|
+
return true if value.is_a?(String) || value.is_a?(Integer)
|
|
313
|
+
|
|
314
|
+
value.is_a?(Float) && value.finite?
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Rack-compatible middleware for Rails, Sinatra, and other Rack-based Ruby apps.
|
|
319
|
+
#
|
|
320
|
+
# The middleware captures completed requests as span events and unhandled app
|
|
321
|
+
# exceptions as issue plus error-span events. It does not require the rack or
|
|
322
|
+
# rails gems at runtime; any app object that responds to `call(env)` is enough.
|
|
323
|
+
class RackMiddleware
|
|
324
|
+
DEFAULT_EVENT_ID_PREFIX = "ruby_rack"
|
|
325
|
+
DEFAULT_SPAN_LOGGER = "rack"
|
|
326
|
+
|
|
327
|
+
def initialize(
|
|
328
|
+
app,
|
|
329
|
+
client:,
|
|
330
|
+
transport: nil,
|
|
331
|
+
flush_on_response: false,
|
|
332
|
+
event_id_prefix: DEFAULT_EVENT_ID_PREFIX,
|
|
333
|
+
metadata: nil,
|
|
334
|
+
timestamp_provider: nil,
|
|
335
|
+
include_exception_backtrace: false,
|
|
336
|
+
on_error: nil,
|
|
337
|
+
raise_errors: false
|
|
338
|
+
)
|
|
339
|
+
raise SdkError.new("validation_error", "rack app must respond to call") unless app.respond_to?(:call)
|
|
340
|
+
Validation.require_non_empty("event id prefix", event_id_prefix)
|
|
341
|
+
raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
@app = app
|
|
344
|
+
@client = client
|
|
345
|
+
@transport = transport
|
|
346
|
+
@flush_on_response = flush_on_response
|
|
347
|
+
@event_id_prefix = event_id_prefix
|
|
348
|
+
@metadata = metadata || {}
|
|
349
|
+
@timestamp_provider = timestamp_provider
|
|
350
|
+
@include_exception_backtrace = include_exception_backtrace
|
|
351
|
+
@on_error = on_error
|
|
352
|
+
@raise_errors = raise_errors
|
|
353
|
+
@next_event_number = 0
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def call(env)
|
|
357
|
+
started_at = monotonic_time
|
|
358
|
+
begin
|
|
359
|
+
response = @app.call(env)
|
|
360
|
+
rescue StandardError => error
|
|
361
|
+
safely_capture do
|
|
362
|
+
elapsed_ms = duration_ms(started_at)
|
|
363
|
+
capture_exception_issue(env, error)
|
|
364
|
+
capture_request_span(env, 500, elapsed_ms, "error")
|
|
365
|
+
flush_if_configured
|
|
366
|
+
end
|
|
367
|
+
raise
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
status_code = rack_status(response)
|
|
371
|
+
safely_capture do
|
|
372
|
+
capture_request_span(env, status_code, duration_ms(started_at), status_code >= 500 ? "error" : "ok")
|
|
373
|
+
flush_if_configured
|
|
374
|
+
end
|
|
375
|
+
response
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def capture_request_span(env, status_code, elapsed_ms, status)
|
|
381
|
+
@client.span(
|
|
382
|
+
next_event_id("span"),
|
|
383
|
+
logbrew_timestamp,
|
|
384
|
+
name: request_name(env),
|
|
385
|
+
traceId: trace_id(env),
|
|
386
|
+
spanId: span_id(env),
|
|
387
|
+
status: status,
|
|
388
|
+
durationMs: elapsed_ms,
|
|
389
|
+
metadata: request_metadata(env, status_code)
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def capture_exception_issue(env, error)
|
|
394
|
+
@client.issue(
|
|
395
|
+
next_event_id("issue"),
|
|
396
|
+
logbrew_timestamp,
|
|
397
|
+
title: error.class.name,
|
|
398
|
+
level: "error",
|
|
399
|
+
message: error.message,
|
|
400
|
+
metadata: exception_metadata(env, error)
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def next_event_id(kind)
|
|
405
|
+
@next_event_number += 1
|
|
406
|
+
"#{@event_id_prefix}_#{kind}_#{@next_event_number}"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def logbrew_timestamp
|
|
410
|
+
timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
|
|
411
|
+
return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
|
|
412
|
+
|
|
413
|
+
timestamp.to_s
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def request_name(env)
|
|
417
|
+
"#{request_method(env)} #{request_path(env)}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def request_method(env)
|
|
421
|
+
value = env_value(env, "REQUEST_METHOD")
|
|
422
|
+
value.nil? || value.empty? ? "GET" : value
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def request_path(env)
|
|
426
|
+
value = env_value(env, "PATH_INFO")
|
|
427
|
+
value = env_value(env, "REQUEST_PATH") if value.nil? || value.empty?
|
|
428
|
+
value = env_value(env, "REQUEST_URI").to_s.split("?", 2)[0] if value.nil? || value.empty?
|
|
429
|
+
value.nil? || value.empty? ? "/" : value
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def trace_id(env)
|
|
433
|
+
env_value(env, "logbrew.trace_id") ||
|
|
434
|
+
env_value(env, "action_dispatch.request_id") ||
|
|
435
|
+
env_value(env, "HTTP_X_REQUEST_ID") ||
|
|
436
|
+
SecureRandom.hex(16)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def span_id(env)
|
|
440
|
+
env_value(env, "logbrew.span_id") || SecureRandom.hex(8)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def request_metadata(env, status_code)
|
|
444
|
+
copy_metadata(@metadata).tap do |metadata|
|
|
445
|
+
metadata["source"] = DEFAULT_SPAN_LOGGER
|
|
446
|
+
metadata["http.method"] = request_method(env)
|
|
447
|
+
metadata["http.path"] = request_path(env)
|
|
448
|
+
metadata["http.status_code"] = status_code
|
|
449
|
+
add_env_metadata(metadata, "rack.url_scheme", env)
|
|
450
|
+
add_env_metadata(metadata, "action_dispatch.request_id", env)
|
|
451
|
+
add_env_metadata(metadata, "HTTP_X_REQUEST_ID", env)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def exception_metadata(env, error)
|
|
456
|
+
request_metadata(env, 500).tap do |metadata|
|
|
457
|
+
metadata["exceptionType"] = error.class.name
|
|
458
|
+
metadata["exceptionMessage"] = error.message
|
|
459
|
+
metadata["exceptionBacktrace"] = error.backtrace.join("\n") if @include_exception_backtrace && error.backtrace
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def rack_status(response)
|
|
464
|
+
return response[0].to_i if response.respond_to?(:[]) && !response[0].nil?
|
|
465
|
+
|
|
466
|
+
500
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def env_value(env, key)
|
|
470
|
+
return nil unless env.respond_to?(:[])
|
|
471
|
+
|
|
472
|
+
value = env[key]
|
|
473
|
+
return nil unless primitive_metadata_value?(value)
|
|
474
|
+
|
|
475
|
+
text = value.to_s
|
|
476
|
+
text.empty? ? nil : text
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def add_env_metadata(metadata, key, env)
|
|
480
|
+
value = env_value(env, key)
|
|
481
|
+
metadata[key] = value unless value.nil?
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def copy_metadata(metadata)
|
|
485
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
486
|
+
copied[key.to_s] = value if primitive_metadata_value?(value)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def primitive_metadata_value?(value)
|
|
491
|
+
return true if value.nil? || value == true || value == false
|
|
492
|
+
return true if value.is_a?(String) || value.is_a?(Integer)
|
|
493
|
+
|
|
494
|
+
value.is_a?(Float) && value.finite?
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def monotonic_time
|
|
498
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def duration_ms(started_at)
|
|
502
|
+
((monotonic_time - started_at) * 1000.0).round(3)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def flush_if_configured
|
|
506
|
+
return unless @flush_on_response && !@transport.nil? && @client.pending_events.positive?
|
|
507
|
+
|
|
508
|
+
@client.flush(@transport)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def safely_capture
|
|
512
|
+
yield
|
|
513
|
+
rescue StandardError => error
|
|
514
|
+
@on_error.call(error) if @on_error.respond_to?(:call)
|
|
515
|
+
raise error if @raise_errors
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Rails.error subscriber for handled and manually reported Rails exceptions.
|
|
520
|
+
#
|
|
521
|
+
# Register an instance with `Rails.error.subscribe(...)` from a Rails
|
|
522
|
+
# initializer. This class avoids a hard Rails dependency so the core gem stays
|
|
523
|
+
# usable in plain Ruby and Rack apps.
|
|
524
|
+
class RailsErrorSubscriber
|
|
525
|
+
DEFAULT_EVENT_ID_PREFIX = "ruby_rails_error"
|
|
526
|
+
SEVERITY_TO_ISSUE_LEVEL = {
|
|
527
|
+
"info" => "info",
|
|
528
|
+
"warning" => "warning",
|
|
529
|
+
"error" => "error"
|
|
530
|
+
}.freeze
|
|
531
|
+
|
|
532
|
+
def initialize(
|
|
533
|
+
client:,
|
|
534
|
+
transport: nil,
|
|
535
|
+
flush_on_report: false,
|
|
536
|
+
event_id_prefix: DEFAULT_EVENT_ID_PREFIX,
|
|
537
|
+
metadata: nil,
|
|
538
|
+
timestamp_provider: nil,
|
|
539
|
+
include_exception_backtrace: false,
|
|
540
|
+
on_error: nil,
|
|
541
|
+
raise_errors: false
|
|
542
|
+
)
|
|
543
|
+
Validation.require_non_empty("event id prefix", event_id_prefix)
|
|
544
|
+
raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
|
|
545
|
+
|
|
546
|
+
@client = client
|
|
547
|
+
@transport = transport
|
|
548
|
+
@flush_on_report = flush_on_report
|
|
549
|
+
@event_id_prefix = event_id_prefix
|
|
550
|
+
@metadata = metadata || {}
|
|
551
|
+
@timestamp_provider = timestamp_provider
|
|
552
|
+
@include_exception_backtrace = include_exception_backtrace
|
|
553
|
+
@on_error = on_error
|
|
554
|
+
@raise_errors = raise_errors
|
|
555
|
+
@next_event_number = 0
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def report(error, handled: true, severity: :error, context: nil, source: nil, **_options)
|
|
559
|
+
capture_safely do
|
|
560
|
+
@next_event_number += 1
|
|
561
|
+
@client.issue(
|
|
562
|
+
"#{@event_id_prefix}_#{@next_event_number}",
|
|
563
|
+
logbrew_timestamp,
|
|
564
|
+
title: error_title(error),
|
|
565
|
+
level: issue_level(severity),
|
|
566
|
+
message: error_message(error),
|
|
567
|
+
metadata: rails_metadata(error, handled, severity, context, source)
|
|
568
|
+
)
|
|
569
|
+
flush_if_configured
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
private
|
|
574
|
+
|
|
575
|
+
def logbrew_timestamp
|
|
576
|
+
timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
|
|
577
|
+
return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
|
|
578
|
+
|
|
579
|
+
timestamp.to_s
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def error_title(error)
|
|
583
|
+
return error.class.name if error.is_a?(Exception)
|
|
584
|
+
|
|
585
|
+
"RailsError"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def error_message(error)
|
|
589
|
+
return error.message if error.is_a?(Exception)
|
|
590
|
+
return error if error.is_a?(String)
|
|
591
|
+
|
|
592
|
+
error.inspect
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def issue_level(severity)
|
|
596
|
+
SEVERITY_TO_ISSUE_LEVEL.fetch(severity.to_s, "error")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def rails_metadata(error, handled, severity, context, source)
|
|
600
|
+
copy_metadata(@metadata).tap do |metadata|
|
|
601
|
+
metadata["source"] = "rails.error"
|
|
602
|
+
metadata["rails.handled"] = handled ? true : false
|
|
603
|
+
metadata["rails.severity"] = severity.to_s
|
|
604
|
+
metadata["rails.source"] = source.to_s if primitive_metadata_value?(source) && !source.to_s.empty?
|
|
605
|
+
add_context_metadata(metadata, context)
|
|
606
|
+
add_exception_metadata(metadata, error) if error.is_a?(Exception)
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def add_context_metadata(metadata, context)
|
|
611
|
+
return if context.nil?
|
|
612
|
+
return unless context.is_a?(Hash)
|
|
613
|
+
|
|
614
|
+
context.each do |key, value|
|
|
615
|
+
metadata["context.#{key}"] = value if primitive_metadata_value?(value)
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def add_exception_metadata(metadata, exception)
|
|
620
|
+
metadata["exceptionType"] = exception.class.name
|
|
621
|
+
metadata["exceptionMessage"] = exception.message
|
|
622
|
+
metadata["exceptionBacktrace"] = exception.backtrace.join("\n") if @include_exception_backtrace && exception.backtrace
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def copy_metadata(metadata)
|
|
626
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
627
|
+
copied[key.to_s] = value if primitive_metadata_value?(value)
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def primitive_metadata_value?(value)
|
|
632
|
+
return true if value.nil? || value == true || value == false
|
|
633
|
+
return true if value.is_a?(String) || value.is_a?(Integer)
|
|
634
|
+
|
|
635
|
+
value.is_a?(Float) && value.finite?
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def flush_if_configured
|
|
639
|
+
return unless @flush_on_report && !@transport.nil? && @client.pending_events.positive?
|
|
640
|
+
|
|
641
|
+
@client.flush(@transport)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def capture_safely
|
|
645
|
+
yield
|
|
646
|
+
rescue StandardError => error
|
|
647
|
+
@on_error.call(error) if @on_error.respond_to?(:call)
|
|
648
|
+
raise error if @raise_errors
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
module Validation
|
|
653
|
+
module_function
|
|
654
|
+
|
|
655
|
+
def require_non_empty(label, value)
|
|
656
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
657
|
+
|
|
658
|
+
raise SdkError.new("validation_error", "#{label} must be non-empty")
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def require_allowed_value(label, value, allowed_values)
|
|
662
|
+
require_non_empty(label, value)
|
|
663
|
+
return if allowed_values.include?(value)
|
|
664
|
+
|
|
665
|
+
raise SdkError.new("validation_error", "#{label} must be one of: #{allowed_values.join(', ')}")
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def require_timestamp(timestamp)
|
|
669
|
+
require_non_empty("timestamp", timestamp)
|
|
670
|
+
return if timestamp.end_with?("Z")
|
|
671
|
+
|
|
672
|
+
time_parts = timestamp.split("T", 2)
|
|
673
|
+
raise timestamp_error(timestamp) if time_parts.length < 2
|
|
674
|
+
|
|
675
|
+
time_portion = time_parts[1]
|
|
676
|
+
return if time_portion.include?("+")
|
|
677
|
+
return if time_portion.rindex("-") && time_portion.rindex("-").positive?
|
|
678
|
+
|
|
679
|
+
raise timestamp_error(timestamp)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def require_metadata(metadata)
|
|
683
|
+
return nil if metadata.nil?
|
|
684
|
+
raise SdkError.new("validation_error", "metadata must be an object") unless metadata.is_a?(Hash)
|
|
685
|
+
|
|
686
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
687
|
+
normalized_key = key.to_s
|
|
688
|
+
require_non_empty("metadata key", normalized_key)
|
|
689
|
+
unless value.nil? || value.is_a?(String) || value.is_a?(Integer) || value.is_a?(Float) ||
|
|
690
|
+
value == true || value == false
|
|
691
|
+
raise SdkError.new(
|
|
692
|
+
"validation_error",
|
|
693
|
+
"metadata value for #{normalized_key} must be a string, number, boolean, or null"
|
|
694
|
+
)
|
|
695
|
+
end
|
|
696
|
+
raise SdkError.new("validation_error", "metadata value for #{normalized_key} must be finite") if numeric_nan?(value)
|
|
697
|
+
|
|
698
|
+
copied[normalized_key] = value
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def read(attributes, key)
|
|
703
|
+
return nil unless attributes.is_a?(Hash)
|
|
704
|
+
|
|
705
|
+
attributes[key] || attributes[key.to_sym]
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def timestamp_error(timestamp)
|
|
709
|
+
SdkError.new("validation_error", "timestamp must include a timezone offset: #{timestamp}")
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def numeric_nan?(value)
|
|
713
|
+
value.is_a?(Float) && (value.nan? || value.infinite?)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
class Client
|
|
718
|
+
def self.create(api_key:, sdk_name:, sdk_version:, max_retries: 2)
|
|
719
|
+
Validation.require_non_empty("api_key", api_key)
|
|
720
|
+
Validation.require_non_empty("sdk_name", sdk_name)
|
|
721
|
+
Validation.require_non_empty("sdk_version", sdk_version)
|
|
722
|
+
raise SdkError.new("validation_error", "max_retries must be non-negative") if max_retries.negative?
|
|
723
|
+
|
|
724
|
+
new(
|
|
725
|
+
api_key: api_key,
|
|
726
|
+
sdk: { "name" => sdk_name, "language" => "ruby", "version" => sdk_version },
|
|
727
|
+
max_retries: max_retries
|
|
728
|
+
)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def initialize(api_key:, sdk:, max_retries:)
|
|
732
|
+
@api_key = api_key
|
|
733
|
+
@sdk = sdk
|
|
734
|
+
@max_retries = max_retries
|
|
735
|
+
@events = []
|
|
736
|
+
@closed = false
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def pending_events
|
|
740
|
+
@events.length
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def preview_json
|
|
744
|
+
JSON.pretty_generate("sdk" => @sdk, "events" => @events)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def release(id, timestamp, attributes)
|
|
748
|
+
push_event("release", id, timestamp, validate_release(attributes))
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def environment(id, timestamp, attributes)
|
|
752
|
+
push_event("environment", id, timestamp, validate_environment(attributes))
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def issue(id, timestamp, attributes)
|
|
756
|
+
push_event("issue", id, timestamp, validate_issue(attributes))
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def log(id, timestamp, attributes)
|
|
760
|
+
push_event("log", id, timestamp, validate_log(attributes))
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def span(id, timestamp, attributes)
|
|
764
|
+
push_event("span", id, timestamp, validate_span(attributes))
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def action(id, timestamp, attributes)
|
|
768
|
+
push_event("action", id, timestamp, validate_action(attributes))
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def flush(transport)
|
|
772
|
+
raise SdkError.new("shutdown_error", "client is already shut down") if @closed
|
|
773
|
+
|
|
774
|
+
flush_internal(transport)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def shutdown(transport)
|
|
778
|
+
raise SdkError.new("shutdown_error", "client is already shut down") if @closed
|
|
779
|
+
|
|
780
|
+
response = flush_internal(transport)
|
|
781
|
+
@closed = true
|
|
782
|
+
response
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
private
|
|
786
|
+
|
|
787
|
+
def push_event(type, id, timestamp, attributes)
|
|
788
|
+
raise SdkError.new("shutdown_error", "client is already shut down") if @closed
|
|
789
|
+
|
|
790
|
+
Validation.require_non_empty("event id", id)
|
|
791
|
+
Validation.require_timestamp(timestamp)
|
|
792
|
+
@events << {
|
|
793
|
+
"type" => type,
|
|
794
|
+
"timestamp" => timestamp,
|
|
795
|
+
"id" => id,
|
|
796
|
+
"attributes" => attributes
|
|
797
|
+
}
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def flush_internal(transport)
|
|
801
|
+
return TransportResponse.new(204, 0) if @events.empty?
|
|
802
|
+
|
|
803
|
+
body = preview_json
|
|
804
|
+
max_attempts = @max_retries + 1
|
|
805
|
+
(1..max_attempts).each do |attempt|
|
|
806
|
+
begin
|
|
807
|
+
response = transport.send(@api_key, body)
|
|
808
|
+
raise SdkError.new("unauthenticated", "transport rejected the API key") if response.status_code == 401
|
|
809
|
+
|
|
810
|
+
if response.status_code >= 200 && response.status_code < 300
|
|
811
|
+
@events.clear
|
|
812
|
+
return TransportResponse.new(response.status_code, attempt)
|
|
813
|
+
end
|
|
814
|
+
next if response.status_code >= 500 && attempt < max_attempts
|
|
815
|
+
|
|
816
|
+
raise SdkError.new("transport_error", "unexpected transport status #{response.status_code}")
|
|
817
|
+
rescue TransportError => error
|
|
818
|
+
next if error.retryable && attempt < max_attempts
|
|
819
|
+
|
|
820
|
+
raise SdkError.new(error.code, error.message)
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
raise SdkError.new("transport_error", "exhausted retries")
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def validate_release(attributes)
|
|
827
|
+
version = Validation.read(attributes, "version")
|
|
828
|
+
Validation.require_non_empty("release version", version)
|
|
829
|
+
commit = Validation.read(attributes, "commit")
|
|
830
|
+
Validation.require_non_empty("release commit", commit) unless commit.nil?
|
|
831
|
+
with_metadata(
|
|
832
|
+
{
|
|
833
|
+
"version" => version
|
|
834
|
+
}.tap do |payload|
|
|
835
|
+
payload["commit"] = commit unless commit.nil?
|
|
836
|
+
notes = Validation.read(attributes, "notes")
|
|
837
|
+
payload["notes"] = notes unless notes.nil?
|
|
838
|
+
end,
|
|
839
|
+
attributes
|
|
840
|
+
)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def validate_environment(attributes)
|
|
844
|
+
name = Validation.read(attributes, "name")
|
|
845
|
+
Validation.require_non_empty("environment name", name)
|
|
846
|
+
with_metadata(
|
|
847
|
+
{
|
|
848
|
+
"name" => name
|
|
849
|
+
}.tap do |payload|
|
|
850
|
+
region = Validation.read(attributes, "region")
|
|
851
|
+
payload["region"] = region unless region.nil?
|
|
852
|
+
end,
|
|
853
|
+
attributes
|
|
854
|
+
)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def validate_issue(attributes)
|
|
858
|
+
title = Validation.read(attributes, "title")
|
|
859
|
+
level = Validation.read(attributes, "level")
|
|
860
|
+
Validation.require_non_empty("issue title", title)
|
|
861
|
+
Validation.require_allowed_value("issue level", level, ISSUE_LEVELS)
|
|
862
|
+
with_metadata(
|
|
863
|
+
{
|
|
864
|
+
"title" => title,
|
|
865
|
+
"level" => level
|
|
866
|
+
}.tap do |payload|
|
|
867
|
+
message = Validation.read(attributes, "message")
|
|
868
|
+
payload["message"] = message unless message.nil?
|
|
869
|
+
end,
|
|
870
|
+
attributes
|
|
871
|
+
)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def validate_log(attributes)
|
|
875
|
+
message = Validation.read(attributes, "message")
|
|
876
|
+
level = Validation.read(attributes, "level")
|
|
877
|
+
Validation.require_non_empty("log message", message)
|
|
878
|
+
Validation.require_allowed_value("log level", level, LOG_LEVELS)
|
|
879
|
+
with_metadata(
|
|
880
|
+
{
|
|
881
|
+
"message" => message,
|
|
882
|
+
"level" => level
|
|
883
|
+
}.tap do |payload|
|
|
884
|
+
logger = Validation.read(attributes, "logger")
|
|
885
|
+
payload["logger"] = logger unless logger.nil?
|
|
886
|
+
end,
|
|
887
|
+
attributes
|
|
888
|
+
)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def validate_span(attributes)
|
|
892
|
+
name = Validation.read(attributes, "name")
|
|
893
|
+
trace_id = Validation.read(attributes, "traceId")
|
|
894
|
+
span_id = Validation.read(attributes, "spanId")
|
|
895
|
+
status = Validation.read(attributes, "status")
|
|
896
|
+
Validation.require_non_empty("span name", name)
|
|
897
|
+
Validation.require_non_empty("span traceId", trace_id)
|
|
898
|
+
Validation.require_non_empty("span spanId", span_id)
|
|
899
|
+
Validation.require_allowed_value("span status", status, SPAN_STATUSES)
|
|
900
|
+
|
|
901
|
+
parent_span_id = Validation.read(attributes, "parentSpanId")
|
|
902
|
+
Validation.require_non_empty("span parentSpanId", parent_span_id) unless parent_span_id.nil?
|
|
903
|
+
duration_ms = Validation.read(attributes, "durationMs")
|
|
904
|
+
if !duration_ms.nil? && (!duration_ms.is_a?(Numeric) || duration_ms.negative?)
|
|
905
|
+
raise SdkError.new("validation_error", "span durationMs must be non-negative")
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
with_metadata(
|
|
909
|
+
{
|
|
910
|
+
"name" => name,
|
|
911
|
+
"traceId" => trace_id,
|
|
912
|
+
"spanId" => span_id
|
|
913
|
+
}.tap do |payload|
|
|
914
|
+
payload["parentSpanId"] = parent_span_id unless parent_span_id.nil?
|
|
915
|
+
payload["status"] = status
|
|
916
|
+
payload["durationMs"] = duration_ms unless duration_ms.nil?
|
|
917
|
+
end,
|
|
918
|
+
attributes
|
|
919
|
+
)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def validate_action(attributes)
|
|
923
|
+
name = Validation.read(attributes, "name")
|
|
924
|
+
status = Validation.read(attributes, "status")
|
|
925
|
+
Validation.require_non_empty("action name", name)
|
|
926
|
+
Validation.require_allowed_value("action status", status, ACTION_STATUSES)
|
|
927
|
+
with_metadata({ "name" => name, "status" => status }, attributes)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def with_metadata(payload, attributes)
|
|
931
|
+
metadata = Validation.require_metadata(Validation.read(attributes, "metadata"))
|
|
932
|
+
payload["metadata"] = metadata unless metadata.nil?
|
|
933
|
+
payload
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: logbrew-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- LogBrew
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Public LogBrew Ruby SDK for building, validating, and flushing event
|
|
14
|
+
batches.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- examples/Makefile
|
|
22
|
+
- examples/readme_example.rb
|
|
23
|
+
- examples/real_user_smoke.rb
|
|
24
|
+
- lib/logbrew.rb
|
|
25
|
+
homepage: https://github.com/LogBrewCo/sdk
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/LogBrewCo/sdk
|
|
30
|
+
post_install_message:
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.6'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.5.22
|
|
46
|
+
signing_key:
|
|
47
|
+
specification_version: 4
|
|
48
|
+
summary: Public LogBrew Ruby SDK
|
|
49
|
+
test_files: []
|