obtrace-sdk-ruby 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7468e55374f6c051c0def7f03f87796ebab9d4d69ff05132965e66b57bdbadb
4
+ data.tar.gz: a1d5629df790ebe12837155bb1bf94a9c61edd4ac737450ca4a3a24466dc144f
5
+ SHA512:
6
+ metadata.gz: 77d495287ed0a0f010b57c08e860cec323a138f8a7c23e46051a9bd23abc8b8cc5c4dd6d717c08fefb8aac49d6285d57329846c830bd5a30dcaf3b4cb84bfbbe
7
+ data.tar.gz: 49fb1d19befa93b2d97485d70d1bf3106e64d340c3e26eb96f7c035e63adfbdff963da1b05787e2b06caedde5f8c7d61ab23fb4282378c29e3766b4ef12b6312
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-present Obtrace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
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:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # obtrace-sdk-ruby
2
+
3
+ Ruby backend SDK for Obtrace telemetry transport and instrumentation.
4
+
5
+ ## Scope
6
+ - OTLP logs/traces/metrics transport
7
+ - Context propagation
8
+ - Rack/Rails middleware baseline
9
+
10
+ ## Design Principle
11
+ SDK is thin/dumb.
12
+ - No business logic authority in client SDK.
13
+ - Policy and product logic are server-side.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # when published as gem
19
+ gem install obtrace-sdk-ruby
20
+ ```
21
+
22
+ Current workspace usage:
23
+
24
+ ```ruby
25
+ require_relative "lib/obtrace_sdk"
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Required:
31
+ - `api_key`
32
+ - `ingest_base_url`
33
+ - `service_name`
34
+
35
+ Optional (auto-resolved from API key on the server side):
36
+ - `tenant_id`
37
+ - `project_id`
38
+ - `app_id`
39
+ - `env`
40
+ - `service_version`
41
+
42
+ ## Quickstart
43
+
44
+ ### Simplified setup
45
+
46
+ The API key resolves `tenant_id`, `project_id`, `app_id`, and `env` automatically on the server side, so only three fields are needed:
47
+
48
+ ```ruby
49
+ require "obtrace_sdk"
50
+
51
+ cfg = ObtraceSDK::Config.new(
52
+ api_key: "obt_live_...",
53
+ ingest_base_url: "https://ingest.obtrace.io",
54
+ service_name: "my-service"
55
+ )
56
+
57
+ client = ObtraceSDK::Client.new(cfg)
58
+ ```
59
+
60
+ ### Full configuration
61
+
62
+ For advanced use cases you can override the resolved values explicitly:
63
+
64
+ ```ruby
65
+ require_relative "lib/obtrace_sdk"
66
+
67
+ cfg = ObtraceSDK::Config.new(
68
+ api_key: "<API_KEY>",
69
+ ingest_base_url: "https://inject.obtrace.ai",
70
+ service_name: "ruby-api"
71
+ )
72
+
73
+ client = ObtraceSDK::Client.new(cfg)
74
+ client.log("info", "started")
75
+ client.metric(ObtraceSDK::SemanticMetrics::RUNTIME_CPU_UTILIZATION, 0.41)
76
+ client.span("checkout.charge", attrs: {
77
+ "feature.name" => "checkout",
78
+ "payment.provider" => "stripe"
79
+ })
80
+ client.flush
81
+ ```
82
+
83
+ ## Canonical metrics and custom spans
84
+
85
+ - Use `ObtraceSDK::SemanticMetrics::*` for globally normalized metric names.
86
+ - Custom spans use `client.span("name", attrs: {...})`.
87
+ - Keep free-form metric names only for application-specific signals outside the shared catalog.
88
+
89
+ ## Frameworks
90
+
91
+ - Rack-compatible middleware baseline for Rails usage
92
+ - Reference docs:
93
+ - `docs/frameworks.md`
94
+
95
+ ## Production Hardening
96
+
97
+ 1. Keep API keys in environment/secret managers.
98
+ 2. Separate keys per environment.
99
+ 3. Use graceful shutdown hooks to flush queue before exit.
100
+ 4. Validate telemetry flow after deploy.
101
+
102
+ ## Troubleshooting
103
+
104
+ - Missing telemetry: verify endpoint reachability and auth key.
105
+ - Missing correlation: ensure propagation headers are injected.
106
+ - Debug transport with `debug: true` in config.
107
+
108
+ ## Documentation
109
+ - Docs index: `docs/index.md`
110
+ - LLM context file: `llm.txt`
111
+ - MCP metadata: `mcp.json`
112
+
113
+ ## Reference
@@ -0,0 +1,208 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "thread"
5
+ require_relative "context"
6
+ require_relative "otlp"
7
+ require_relative "http_instrumentation"
8
+ require_relative "logger_capture"
9
+
10
+ module ObtraceSDK
11
+ class Client
12
+ def initialize(cfg)
13
+ raise ArgumentError, "api_key, ingest_base_url and service_name are required" if cfg.api_key.to_s.empty? || cfg.ingest_base_url.to_s.empty? || cfg.service_name.to_s.empty?
14
+
15
+ @cfg = cfg
16
+ @queue = []
17
+ @lock = Mutex.new
18
+ @http = nil
19
+ @http_uri = nil
20
+ @circuit_failures = 0
21
+ @circuit_open_until = Time.at(0)
22
+ @seen_exceptions = {}
23
+ @seen_lock = Mutex.new
24
+
25
+ install_exception_tracepoint
26
+ HttpInstrumentation.install(self) if @cfg.auto_instrument_http
27
+ LoggerCapture.install(self) if @cfg.auto_capture_logs
28
+ at_exit { capture_fatal; shutdown }
29
+ end
30
+
31
+ def log(level, message, context = nil)
32
+ enqueue("/otlp/v1/logs", Otlp.logs_payload(@cfg, level, truncate(message, 32768), context))
33
+ end
34
+
35
+ def metric(name, value, unit = "1", context = nil)
36
+ warn("[obtrace-sdk-ruby] non-canonical metric name: #{name}") if @cfg.validate_semantic_metrics && @cfg.debug && !SemanticMetrics.semantic_metric?(name)
37
+ enqueue("/otlp/v1/metrics", Otlp.metric_payload(@cfg, truncate(name, 1024), value, unit, context))
38
+ end
39
+
40
+ def span(name, trace_id: nil, span_id: nil, start_unix_nano: nil, end_unix_nano: nil, status_code: nil, status_message: "", attrs: nil)
41
+ trace_id ||= Context.random_hex(16)
42
+ span_id ||= Context.random_hex(8)
43
+ start_ns = start_unix_nano || Otlp.now_unix_nano
44
+ end_ns = end_unix_nano || Otlp.now_unix_nano
45
+
46
+ name = truncate(name, 32768)
47
+ if attrs
48
+ attrs = attrs.transform_values { |v| v.is_a?(String) ? truncate(v, 4096) : v }
49
+ end
50
+
51
+ enqueue("/otlp/v1/traces", Otlp.span_payload(@cfg, name, trace_id, span_id, start_ns, end_ns, status_code, status_message, attrs))
52
+ { trace_id: trace_id, span_id: span_id }
53
+ end
54
+
55
+ def inject_propagation(headers = {}, trace_id: nil, span_id: nil, session_id: nil)
56
+ Context.ensure_propagation_headers(headers, trace_id: trace_id, span_id: span_id, session_id: session_id)
57
+ end
58
+
59
+ def flush
60
+ batch = []
61
+ @lock.synchronize do
62
+ return if Time.now < @circuit_open_until
63
+ half_open = @circuit_failures >= 5
64
+ if half_open
65
+ return if @queue.empty?
66
+ batch = [@queue.shift]
67
+ else
68
+ batch = @queue.dup
69
+ @queue.clear
70
+ end
71
+ end
72
+ batch.each do |item|
73
+ success = send_item(item)
74
+ @lock.synchronize do
75
+ if success
76
+ if @circuit_failures > 0
77
+ warn("[obtrace-sdk-ruby] circuit breaker closed") if @cfg.debug
78
+ @circuit_failures = 0
79
+ @circuit_open_until = Time.at(0)
80
+ end
81
+ else
82
+ @circuit_failures += 1
83
+ if @circuit_failures >= 5
84
+ @circuit_open_until = Time.now + 30
85
+ warn("[obtrace-sdk-ruby] circuit breaker opened") if @cfg.debug
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def shutdown
93
+ @tracepoint&.disable
94
+ flush
95
+ @lock.synchronize do
96
+ if @http
97
+ @http.finish rescue nil
98
+ @http = nil
99
+ @http_uri = nil
100
+ end
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def install_exception_tracepoint
107
+ client = self
108
+ @tracepoint = TracePoint.new(:raise) do |tp|
109
+ ex = tp.raised_exception
110
+ eid = ex.object_id
111
+ seen = client.instance_variable_get(:@seen_lock).synchronize do
112
+ cache = client.instance_variable_get(:@seen_exceptions)
113
+ next true if cache[eid]
114
+ cache[eid] = true
115
+ cache.shift if cache.size > 200
116
+ false
117
+ end
118
+ unless seen
119
+ bt = (ex.backtrace || []).first(10).join("\n")
120
+ client.log("error", "#{ex.class}: #{ex.message}", {
121
+ "exception.type" => ex.class.to_s,
122
+ "exception.message" => ex.message.to_s,
123
+ "exception.stacktrace" => bt,
124
+ "code.filepath" => tp.path.to_s,
125
+ "code.lineno" => tp.lineno.to_s,
126
+ "auto.source" => "tracepoint"
127
+ })
128
+ end
129
+ end
130
+ @tracepoint.enable
131
+ end
132
+
133
+ def capture_fatal
134
+ return unless $!
135
+ ex = $!
136
+ bt = (ex.backtrace || []).first(10).join("\n")
137
+ log("fatal", "#{ex.class}: #{ex.message}", {
138
+ "exception.type" => ex.class.to_s,
139
+ "exception.message" => ex.message.to_s,
140
+ "exception.stacktrace" => bt,
141
+ "auto.source" => "at_exit"
142
+ })
143
+ end
144
+
145
+ def truncate(s, max)
146
+ return s if s.length <= max
147
+ s[0, max] + "...[truncated]"
148
+ end
149
+
150
+ def enqueue(endpoint, payload)
151
+ @lock.synchronize do
152
+ if @queue.length >= @cfg.max_queue_size
153
+ @queue.shift
154
+ warn("[obtrace-sdk-ruby] queue full, dropping oldest item") if @cfg.debug
155
+ end
156
+ @queue << { endpoint: endpoint, payload: payload.dup.freeze }
157
+ end
158
+ end
159
+
160
+ def connection_for(uri)
161
+ if @http && @http_uri && @http_uri.host == uri.host && @http_uri.port == uri.port
162
+ begin
163
+ return @http if @http.started?
164
+ rescue StandardError
165
+ end
166
+ end
167
+
168
+ @http.finish rescue nil if @http
169
+ @http = Net::HTTP.new(uri.host, uri.port)
170
+ @http.use_ssl = uri.scheme == "https"
171
+ @http.read_timeout = @cfg.request_timeout_sec
172
+ @http.start
173
+ @http_uri = uri
174
+ @http
175
+ end
176
+
177
+ def send_item(item)
178
+ uri = URI.parse("#{@cfg.ingest_base_url.to_s.sub(%r{/$}, "")}#{item[:endpoint]}")
179
+ http = connection_for(uri)
180
+ req = Net::HTTP::Post.new(uri.request_uri)
181
+ req["Authorization"] = "Bearer #{@cfg.api_key}"
182
+ req["Content-Type"] = "application/json"
183
+ (@cfg.default_headers || {}).each { |k, v| req[k] = v.to_s }
184
+ req.body = JSON.generate(item[:payload])
185
+
186
+ retries = 0
187
+ begin
188
+ res = http.request(req)
189
+ if res.code.to_i >= 300
190
+ warn("[obtrace-sdk-ruby] status=#{res.code} endpoint=#{item[:endpoint]} body=#{res.body}") if @cfg.debug
191
+ return false
192
+ end
193
+ true
194
+ rescue StandardError => e
195
+ if retries < 2
196
+ retries += 1
197
+ sleep 1
198
+ @http.finish rescue nil
199
+ @http = nil
200
+ http = connection_for(uri)
201
+ retry
202
+ end
203
+ warn("[obtrace-sdk-ruby] send failed endpoint=#{item[:endpoint]} err=#{e.message}") if @cfg.debug
204
+ false
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,18 @@
1
+ require "securerandom"
2
+
3
+ module ObtraceSDK
4
+ module Context
5
+ module_function
6
+
7
+ def random_hex(bytes)
8
+ SecureRandom.hex(bytes)
9
+ end
10
+
11
+ def ensure_propagation_headers(headers = {}, trace_id: nil, span_id: nil, session_id: nil)
12
+ out = (headers || {}).dup
13
+ out["traceparent"] ||= "00-#{trace_id || random_hex(16)}-#{span_id || random_hex(8)}-01"
14
+ out["x-obtrace-session-id"] ||= session_id if session_id && !session_id.empty?
15
+ out
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module ObtraceSDK
2
+ module Framework
3
+ module_function
4
+
5
+ # Rack-compatible middleware baseline used by Rails.
6
+ def rack_middleware(client, app)
7
+ lambda do |env|
8
+ client.log("info", "request.start", { method: env["REQUEST_METHOD"], path: env["PATH_INFO"] })
9
+ status, headers, body = app.call(env)
10
+ client.log("info", "request.finish", { status: status.to_i })
11
+ [status, headers, body]
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,122 @@
1
+ require "net/http"
2
+
3
+ module ObtraceSDK
4
+ module HttpInstrumentation
5
+ @client = nil
6
+ @installed = false
7
+
8
+ module_function
9
+
10
+ def install(client)
11
+ return if @installed
12
+ @client = client
13
+ @installed = true
14
+
15
+ Net::HTTP.prepend(NetHttpPatch)
16
+ end
17
+
18
+ def client
19
+ @client
20
+ end
21
+
22
+ def installed?
23
+ @installed
24
+ end
25
+
26
+ def reset!
27
+ @client = nil
28
+ @installed = false
29
+ end
30
+
31
+ module NetHttpPatch
32
+ def request(req, body = nil, &block)
33
+ client = ObtraceSDK::HttpInstrumentation.client
34
+ unless client
35
+ return super
36
+ end
37
+
38
+ uri = URI.parse("#{use_ssl? ? 'https' : 'http'}://#{address}:#{port}#{req.path}")
39
+
40
+ if uri.host && ObtraceSDK::HttpInstrumentation.own_endpoint?(uri)
41
+ return super
42
+ end
43
+
44
+ trace_id = ObtraceSDK::Context.random_hex(16)
45
+ span_id = ObtraceSDK::Context.random_hex(8)
46
+
47
+ req["traceparent"] ||= "00-#{trace_id}-#{span_id}-01"
48
+
49
+ start_ns = ObtraceSDK::Otlp.now_unix_nano
50
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ status_code = nil
52
+ error = nil
53
+
54
+ begin
55
+ response = super
56
+ status_code = response.code.to_i
57
+ response
58
+ rescue => e
59
+ error = e
60
+ raise
61
+ ensure
62
+ end_ns = ObtraceSDK::Otlp.now_unix_nano
63
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
64
+
65
+ attrs = {
66
+ "http.method" => req.method,
67
+ "http.url" => uri.to_s,
68
+ "http.host" => uri.host.to_s,
69
+ "net.peer.port" => uri.port.to_s,
70
+ "http.duration_ms" => duration_ms,
71
+ "auto.source" => "http_instrumentation"
72
+ }
73
+
74
+ if status_code
75
+ attrs["http.status_code"] = status_code
76
+ end
77
+
78
+ if error
79
+ attrs["error"] = true
80
+ attrs["error.type"] = error.class.to_s
81
+ attrs["error.message"] = error.message.to_s
82
+ end
83
+
84
+ span_status = if error
85
+ status_code = 500
86
+ 500
87
+ elsif status_code && status_code >= 400
88
+ status_code
89
+ else
90
+ nil
91
+ end
92
+
93
+ client.span(
94
+ "HTTP #{req.method}",
95
+ trace_id: trace_id,
96
+ span_id: span_id,
97
+ start_unix_nano: start_ns,
98
+ end_unix_nano: end_ns,
99
+ status_code: span_status,
100
+ status_message: error ? error.message : "",
101
+ attrs: attrs
102
+ )
103
+
104
+ client.log(
105
+ status_code && status_code >= 400 ? "warn" : "info",
106
+ "HTTP #{req.method} #{uri.host}#{uri.path} #{status_code || 'ERR'}",
107
+ attrs
108
+ )
109
+ end
110
+ end
111
+ end
112
+
113
+ def self.own_endpoint?(uri)
114
+ return false unless @client
115
+ cfg = @client.instance_variable_get(:@cfg)
116
+ return false unless cfg
117
+ ingest_uri = URI.parse(cfg.ingest_base_url.to_s) rescue nil
118
+ return false unless ingest_uri
119
+ uri.host == ingest_uri.host && uri.port == ingest_uri.port
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,68 @@
1
+ require "logger"
2
+
3
+ module ObtraceSDK
4
+ module LoggerCapture
5
+ @client = nil
6
+ @installed = false
7
+
8
+ SEVERITY_MAP = {
9
+ 0 => "debug",
10
+ 1 => "info",
11
+ 2 => "warn",
12
+ 3 => "error",
13
+ 4 => "fatal",
14
+ 5 => "unknown"
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def install(client)
20
+ return if @installed
21
+ @client = client
22
+ @installed = true
23
+
24
+ ::Logger.prepend(LoggerPatch)
25
+ end
26
+
27
+ def client
28
+ @client
29
+ end
30
+
31
+ def installed?
32
+ @installed
33
+ end
34
+
35
+ def reset!
36
+ @client = nil
37
+ @installed = false
38
+ end
39
+
40
+ module LoggerPatch
41
+ def add(severity, message = nil, progname = nil, &block)
42
+ result = super
43
+
44
+ client = ObtraceSDK::LoggerCapture.client
45
+ if client
46
+ if message.nil?
47
+ if block
48
+ msg = block.call
49
+ else
50
+ msg = progname
51
+ end
52
+ else
53
+ msg = message
54
+ end
55
+
56
+ if msg && !msg.to_s.empty?
57
+ level = ObtraceSDK::LoggerCapture::SEVERITY_MAP[severity] || "unknown"
58
+ attrs = { "auto.source" => "logger_capture" }
59
+ attrs["logger.progname"] = progname.to_s if progname && message
60
+ client.log(level, msg.to_s, attrs)
61
+ end
62
+ end
63
+
64
+ result
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,86 @@
1
+ module ObtraceSDK
2
+ class Middleware
3
+ def initialize(app, client)
4
+ @app = app
5
+ @client = client
6
+ end
7
+
8
+ def call(env)
9
+ trace_id = extract_trace_id(env) || Context.random_hex(16)
10
+ span_id = Context.random_hex(8)
11
+ start_ns = Otlp.now_unix_nano
12
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
+
14
+ status = nil
15
+ error = nil
16
+
17
+ begin
18
+ status, headers, body = @app.call(env)
19
+ [status, headers, body]
20
+ rescue => e
21
+ error = e
22
+ raise
23
+ ensure
24
+ end_ns = Otlp.now_unix_nano
25
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
26
+ status_code = error ? 500 : status.to_i
27
+
28
+ attrs = {
29
+ "http.method" => env["REQUEST_METHOD"],
30
+ "http.target" => env["PATH_INFO"],
31
+ "http.host" => env["HTTP_HOST"].to_s,
32
+ "http.scheme" => env["rack.url_scheme"].to_s,
33
+ "http.status_code" => status_code,
34
+ "http.duration_ms" => duration_ms,
35
+ "http.user_agent" => env["HTTP_USER_AGENT"].to_s,
36
+ "net.peer.ip" => (env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"]).to_s
37
+ }
38
+
39
+ if error
40
+ attrs["error"] = true
41
+ attrs["error.type"] = error.class.to_s
42
+ attrs["error.message"] = error.message.to_s
43
+ end
44
+
45
+ if env["QUERY_STRING"] && !env["QUERY_STRING"].empty?
46
+ attrs["http.query"] = env["QUERY_STRING"]
47
+ end
48
+
49
+ @client.span(
50
+ "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}",
51
+ trace_id: trace_id,
52
+ span_id: span_id,
53
+ start_unix_nano: start_ns,
54
+ end_unix_nano: end_ns,
55
+ status_code: status_code >= 400 ? status_code : nil,
56
+ status_message: error ? error.message : "",
57
+ attrs: attrs
58
+ )
59
+
60
+ level = if status_code >= 500
61
+ "error"
62
+ elsif status_code >= 400
63
+ "warn"
64
+ else
65
+ "info"
66
+ end
67
+
68
+ @client.log(
69
+ level,
70
+ "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} #{status_code} #{duration_ms}ms",
71
+ attrs
72
+ )
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def extract_trace_id(env)
79
+ traceparent = env["HTTP_TRACEPARENT"]
80
+ return nil unless traceparent
81
+ parts = traceparent.split("-")
82
+ return nil unless parts.length >= 3
83
+ parts[1]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,127 @@
1
+ module ObtraceSDK
2
+ module Otlp
3
+ module_function
4
+
5
+ def attrs(hash)
6
+ return [] if hash.nil?
7
+ hash.map do |k, v|
8
+ value =
9
+ case v
10
+ when TrueClass, FalseClass
11
+ { "boolValue" => v }
12
+ when Numeric
13
+ { "doubleValue" => v.to_f }
14
+ else
15
+ { "stringValue" => v.to_s }
16
+ end
17
+ { "key" => k.to_s, "value" => value }
18
+ end
19
+ end
20
+
21
+ def resource(cfg)
22
+ base = {
23
+ "service.name" => cfg.service_name,
24
+ "service.version" => cfg.service_version,
25
+ "deployment.environment" => cfg.env || "dev",
26
+ "runtime.name" => "ruby"
27
+ }
28
+ base["obtrace.tenant_id"] = cfg.tenant_id if cfg.tenant_id
29
+ base["obtrace.project_id"] = cfg.project_id if cfg.project_id
30
+ base["obtrace.app_id"] = cfg.app_id if cfg.app_id
31
+ base["obtrace.env"] = cfg.env if cfg.env
32
+ attrs(base)
33
+ end
34
+
35
+ def now_unix_nano
36
+ (Time.now.to_f * 1_000_000_000).to_i.to_s
37
+ end
38
+
39
+ def logs_payload(cfg, level, message, context = nil)
40
+ context_attrs = { "obtrace.log.level" => level }
41
+ if context
42
+ context.each { |k, v| context_attrs["obtrace.attr.#{k}"] = v }
43
+ end
44
+
45
+ {
46
+ "resourceLogs" => [
47
+ {
48
+ "resource" => { "attributes" => resource(cfg) },
49
+ "scopeLogs" => [
50
+ {
51
+ "scope" => { "name" => "obtrace-sdk-ruby", "version" => "1.0.0" },
52
+ "logRecords" => [
53
+ {
54
+ "timeUnixNano" => now_unix_nano,
55
+ "severityText" => level.to_s.upcase,
56
+ "body" => { "stringValue" => message.to_s },
57
+ "attributes" => attrs(context_attrs)
58
+ }
59
+ ]
60
+ }
61
+ ]
62
+ }
63
+ ]
64
+ }
65
+ end
66
+
67
+ def metric_payload(cfg, name, value, unit = "1", context = nil)
68
+ {
69
+ "resourceMetrics" => [
70
+ {
71
+ "resource" => { "attributes" => resource(cfg) },
72
+ "scopeMetrics" => [
73
+ {
74
+ "scope" => { "name" => "obtrace-sdk-ruby", "version" => "1.0.0" },
75
+ "metrics" => [
76
+ {
77
+ "name" => name.to_s,
78
+ "unit" => unit.to_s,
79
+ "gauge" => {
80
+ "dataPoints" => [
81
+ {
82
+ "timeUnixNano" => now_unix_nano,
83
+ "asDouble" => value.to_f,
84
+ "attributes" => attrs(context || {})
85
+ }
86
+ ]
87
+ }
88
+ }
89
+ ]
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ }
95
+ end
96
+
97
+ def span_payload(cfg, name, trace_id, span_id, start_unix_nano, end_unix_nano, status_code = nil, status_message = "", attrs_hash = nil)
98
+ {
99
+ "resourceSpans" => [
100
+ {
101
+ "resource" => { "attributes" => resource(cfg) },
102
+ "scopeSpans" => [
103
+ {
104
+ "scope" => { "name" => "obtrace-sdk-ruby", "version" => "1.0.0" },
105
+ "spans" => [
106
+ {
107
+ "traceId" => trace_id,
108
+ "spanId" => span_id,
109
+ "name" => name.to_s,
110
+ "kind" => 3,
111
+ "startTimeUnixNano" => start_unix_nano,
112
+ "endTimeUnixNano" => end_unix_nano,
113
+ "attributes" => attrs(attrs_hash || {}),
114
+ "status" => {
115
+ "code" => status_code && status_code.to_i >= 400 ? 2 : 1,
116
+ "message" => status_message.to_s
117
+ }
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ ]
124
+ }
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,37 @@
1
+ require "obtrace_sdk"
2
+
3
+ module ObtraceSDK
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "obtrace_sdk.configure" do |app|
6
+ api_key = credentials_fetch("obtrace_api_key") || ENV["OBTRACE_API_KEY"]
7
+ ingest_url = credentials_fetch("obtrace_ingest_url") || ENV["OBTRACE_INGEST_BASE_URL"] || "https://inject.obtrace.ai"
8
+ service_name = credentials_fetch("obtrace_service_name") || ENV["OBTRACE_SERVICE_NAME"] || ::Rails.application.class.module_parent_name.underscore rescue "rails-app"
9
+
10
+ next unless api_key
11
+
12
+ cfg = ObtraceSDK::Config.new(
13
+ api_key: api_key,
14
+ ingest_base_url: ingest_url,
15
+ service_name: service_name,
16
+ env: ::Rails.env.to_s,
17
+ tenant_id: credentials_fetch("obtrace_tenant_id") || ENV["OBTRACE_TENANT_ID"],
18
+ project_id: credentials_fetch("obtrace_project_id") || ENV["OBTRACE_PROJECT_ID"],
19
+ app_id: credentials_fetch("obtrace_app_id") || ENV["OBTRACE_APP_ID"],
20
+ debug: ENV["OBTRACE_DEBUG"] == "true"
21
+ )
22
+
23
+ client = ObtraceSDK::Client.new(cfg)
24
+ ObtraceSDK.instance_variable_set(:@rails_client, client)
25
+
26
+ app.middleware.insert(0, ObtraceSDK::Middleware, client)
27
+ end
28
+
29
+ def credentials_fetch(key)
30
+ ::Rails.application.credentials.send(key) rescue nil
31
+ end
32
+ end
33
+
34
+ def self.rails_client
35
+ @rails_client
36
+ end
37
+ end
@@ -0,0 +1,55 @@
1
+ module ObtraceSDK
2
+ module SemanticMetrics
3
+ THROUGHPUT = "http_requests_total"
4
+ ERROR_RATE = "http_5xx_total"
5
+ LATENCY_P95 = "latency_p95"
6
+ RUNTIME_CPU_UTILIZATION = "runtime.cpu.utilization"
7
+ RUNTIME_MEMORY_USAGE = "runtime.memory.usage"
8
+ RUNTIME_THREAD_COUNT = "runtime.thread.count"
9
+ RUNTIME_GC_PAUSE = "runtime.gc.pause"
10
+ RUNTIME_EVENTLOOP_LAG = "runtime.eventloop.lag"
11
+ CLUSTER_CPU_UTILIZATION = "cluster.cpu.utilization"
12
+ CLUSTER_MEMORY_USAGE = "cluster.memory.usage"
13
+ CLUSTER_NODE_COUNT = "cluster.node.count"
14
+ CLUSTER_POD_COUNT = "cluster.pod.count"
15
+ DB_OPERATION_LATENCY = "db.operation.latency"
16
+ DB_CLIENT_ERRORS = "db.client.errors"
17
+ DB_CONNECTIONS_USAGE = "db.connections.usage"
18
+ MESSAGING_CONSUMER_LAG = "messaging.consumer.lag"
19
+ WEB_VITAL_LCP = "web.vital.lcp"
20
+ WEB_VITAL_FCP = "web.vital.fcp"
21
+ WEB_VITAL_INP = "web.vital.inp"
22
+ WEB_VITAL_CLS = "web.vital.cls"
23
+ WEB_VITAL_TTFB = "web.vital.ttfb"
24
+ USER_ACTIONS = "obtrace.sim.web.react.actions"
25
+
26
+ ALL = [
27
+ THROUGHPUT,
28
+ ERROR_RATE,
29
+ LATENCY_P95,
30
+ RUNTIME_CPU_UTILIZATION,
31
+ RUNTIME_MEMORY_USAGE,
32
+ RUNTIME_THREAD_COUNT,
33
+ RUNTIME_GC_PAUSE,
34
+ RUNTIME_EVENTLOOP_LAG,
35
+ CLUSTER_CPU_UTILIZATION,
36
+ CLUSTER_MEMORY_USAGE,
37
+ CLUSTER_NODE_COUNT,
38
+ CLUSTER_POD_COUNT,
39
+ DB_OPERATION_LATENCY,
40
+ DB_CLIENT_ERRORS,
41
+ DB_CONNECTIONS_USAGE,
42
+ MESSAGING_CONSUMER_LAG,
43
+ WEB_VITAL_LCP,
44
+ WEB_VITAL_FCP,
45
+ WEB_VITAL_INP,
46
+ WEB_VITAL_CLS,
47
+ WEB_VITAL_TTFB,
48
+ USER_ACTIONS
49
+ ].freeze
50
+
51
+ def self.semantic_metric?(name)
52
+ ALL.include?(name)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ module ObtraceSDK
2
+ class Config
3
+ attr_accessor :api_key, :ingest_base_url, :tenant_id, :project_id, :app_id, :env
4
+ attr_accessor :service_name, :service_version, :max_queue_size, :request_timeout_sec
5
+ attr_accessor :default_headers, :debug, :validate_semantic_metrics
6
+ attr_accessor :auto_instrument_http, :auto_capture_logs
7
+
8
+ def initialize(api_key:, ingest_base_url:, service_name:, tenant_id: nil, project_id: nil, app_id: nil, env: "dev", service_version: "1.0.0", max_queue_size: 1000, request_timeout_sec: 5, default_headers: {}, validate_semantic_metrics: false, debug: false, auto_instrument_http: true, auto_capture_logs: true)
9
+ @api_key = api_key
10
+ @ingest_base_url = ingest_base_url
11
+ @tenant_id = tenant_id
12
+ @project_id = project_id
13
+ @app_id = app_id
14
+ @env = env
15
+ @service_name = service_name
16
+ @service_version = service_version
17
+ @max_queue_size = max_queue_size
18
+ @request_timeout_sec = request_timeout_sec
19
+ @default_headers = default_headers
20
+ @validate_semantic_metrics = validate_semantic_metrics
21
+ @debug = debug
22
+ @auto_instrument_http = auto_instrument_http
23
+ @auto_capture_logs = auto_capture_logs
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module ObtraceSDK
2
+ VERSION = "1.0.2"
3
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "obtrace_sdk/types"
2
+ require_relative "obtrace_sdk/context"
3
+ require_relative "obtrace_sdk/otlp"
4
+ require_relative "obtrace_sdk/http_instrumentation"
5
+ require_relative "obtrace_sdk/logger_capture"
6
+ require_relative "obtrace_sdk/middleware"
7
+ require_relative "obtrace_sdk/client"
8
+ require_relative "obtrace_sdk/framework"
9
+ require_relative "obtrace_sdk/semantic_metrics"
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: obtrace-sdk-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Obtrace
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: net-http
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Ruby SDK for Obtrace observability platform. Captures logs, traces, and
42
+ metrics via OTLP.
43
+ email:
44
+ - dev@obtrace.ai
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - lib/obtrace_sdk.rb
52
+ - lib/obtrace_sdk/client.rb
53
+ - lib/obtrace_sdk/context.rb
54
+ - lib/obtrace_sdk/framework.rb
55
+ - lib/obtrace_sdk/http_instrumentation.rb
56
+ - lib/obtrace_sdk/logger_capture.rb
57
+ - lib/obtrace_sdk/middleware.rb
58
+ - lib/obtrace_sdk/otlp.rb
59
+ - lib/obtrace_sdk/rails.rb
60
+ - lib/obtrace_sdk/semantic_metrics.rb
61
+ - lib/obtrace_sdk/types.rb
62
+ - lib/obtrace_sdk/version.rb
63
+ homepage: https://github.com/obtraceai/obtrace-sdk-ruby
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://github.com/obtraceai/obtrace-sdk-ruby
68
+ source_code_uri: https://github.com/obtraceai/obtrace-sdk-ruby
69
+ changelog_uri: https://github.com/obtraceai/obtrace-sdk-ruby/releases
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.4.20
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Obtrace Ruby SDK — observability for Ruby applications
89
+ test_files: []