obtrace-sdk-ruby 1.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43e85fa41eccf95d2755ea93b8fcbb8f50d878da79328603ac2c12c44b2cf340
4
- data.tar.gz: 33804ea6aa4789cc0d6cb78cf24b8edb26b403d6f3ccab469e74cf0a84c9b1fb
3
+ metadata.gz: c7468e55374f6c051c0def7f03f87796ebab9d4d69ff05132965e66b57bdbadb
4
+ data.tar.gz: a1d5629df790ebe12837155bb1bf94a9c61edd4ac737450ca4a3a24466dc144f
5
5
  SHA512:
6
- metadata.gz: 621bc15209d4ffd408bc3ab0a8ddfa4b915bf68061e98e133bfab7fae97682332ce2672903a66ca6ec75fb24aa6c13a1f2a9727ac0948134262fefd972e49c45
7
- data.tar.gz: d2d643dc2d854ea490d142a909320fea37b7c7f187a94926764725c457fae35a80241e0b0e8721afea058832c2b998fa08dad81d5c13e920fcaddb94f061b4c2
6
+ metadata.gz: 77d495287ed0a0f010b57c08e860cec323a138f8a7c23e46051a9bd23abc8b8cc5c4dd6d717c08fefb8aac49d6285d57329846c830bd5a30dcaf3b4cb84bfbbe
7
+ data.tar.gz: 49fb1d19befa93b2d97485d70d1bf3106e64d340c3e26eb96f7c035e63adfbdff963da1b05787e2b06caedde5f8c7d61ab23fb4282378c29e3766b4ef12b6312
@@ -1,135 +1,208 @@
1
- require "net/http"
2
1
  require "json"
2
+ require "net/http"
3
3
  require "uri"
4
- require_relative "otel_setup"
4
+ require "thread"
5
+ require_relative "context"
6
+ require_relative "otlp"
7
+ require_relative "http_instrumentation"
8
+ require_relative "logger_capture"
5
9
 
6
10
  module ObtraceSDK
7
11
  class Client
8
- @@initialized = false
9
-
10
- attr_reader :tracer, :meter, :handshake_ok
11
-
12
12
  def initialize(cfg)
13
- if @@initialized
14
- warn("[obtrace-sdk-ruby] already initialized, skipping duplicate init")
15
- return
16
- end
17
-
18
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?
19
14
 
20
- @@initialized = true
21
15
  @cfg = cfg
22
- @handshake_ok = false
23
- @tracer_provider = OtelSetup.configure(cfg)
24
- @tracer = @tracer_provider.tracer("obtrace-sdk-ruby", ObtraceSDK::VERSION)
25
- @meter = nil
26
- @meter_warning_logged = false
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
27
30
 
28
- begin
29
- @meter = OpenTelemetry.meter_provider.meter("obtrace-sdk-ruby", ObtraceSDK::VERSION)
30
- rescue => _e
31
- end
31
+ def log(level, message, context = nil)
32
+ enqueue("/otlp/v1/logs", Otlp.logs_payload(@cfg, level, truncate(message, 32768), context))
33
+ end
32
34
 
33
- Thread.new { perform_handshake }
34
- at_exit { shutdown }
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))
35
38
  end
36
39
 
37
- private def perform_handshake
38
- base = @cfg.ingest_base_url.to_s.chomp("/")
39
- return if base.empty?
40
- uri = URI("#{base}/v1/init")
41
- payload = JSON.generate({
42
- sdk: "obtrace-sdk-ruby",
43
- sdk_version: "1.0.1",
44
- service_name: @cfg.service_name,
45
- service_version: @cfg.service_version.to_s,
46
- runtime: "ruby",
47
- runtime_version: RUBY_VERSION,
48
- })
49
- http = Net::HTTP.new(uri.host, uri.port)
50
- http.use_ssl = uri.scheme == "https"
51
- http.open_timeout = 5
52
- http.read_timeout = 5
53
- req = Net::HTTP::Post.new(uri)
54
- req["Content-Type"] = "application/json"
55
- req["Authorization"] = "Bearer #{@cfg.api_key}"
56
- req.body = payload
57
- resp = http.request(req)
58
- if resp.code.to_i == 200
59
- @handshake_ok = true
60
- warn("[obtrace-sdk-ruby] init handshake OK") if @cfg.debug
61
- elsif @cfg.debug
62
- warn("[obtrace-sdk-ruby] init handshake failed: #{resp.code}")
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 }
63
49
  end
64
- rescue => e
65
- warn("[obtrace-sdk-ruby] init handshake error: #{e.message}") if @cfg.debug
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 }
66
53
  end
67
54
 
68
- def log(level, message, context = nil)
69
- attributes = { "obtrace.log.level" => level.to_s }
70
- if context
71
- context.each { |k, v| attributes["obtrace.attr.#{k}"] = v.to_s }
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
72
71
  end
73
- attributes["log.severity"] = level.to_s.upcase
74
- attributes["log.message"] = message.to_s
75
-
76
- span = OpenTelemetry::Trace.current_span
77
- if span && span.context.valid?
78
- span.add_event("log", attributes: attributes)
79
- else
80
- @tracer.in_span("log.#{level}", attributes: attributes) { |_s| }
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
81
89
  end
82
90
  end
83
91
 
84
- def metric(name, value, unit = "1", context = nil)
85
- warn("[obtrace-sdk-ruby] non-canonical metric name: #{name}") if @cfg.validate_semantic_metrics && @cfg.debug && !SemanticMetrics.semantic_metric?(name)
86
-
87
- unless @meter
88
- unless @meter_warning_logged
89
- warn("[obtrace-sdk-ruby] meter provider not available, metrics will be dropped")
90
- @meter_warning_logged = true
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
91
100
  end
92
- return
93
101
  end
102
+ end
94
103
 
95
- attrs = {}
96
- context&.each { |k, v| attrs[k.to_s] = v.to_s }
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
97
132
 
98
- gauge = @meter.create_gauge(name.to_s, unit: unit.to_s)
99
- gauge.set(value.to_f, attributes: attrs)
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
+ })
100
143
  end
101
144
 
102
- def span(name, attrs: nil, &block)
103
- span_attrs = {}
104
- if attrs
105
- attrs.each { |k, v| span_attrs[k.to_s] = v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass) ? v : v.to_s }
106
- end
145
+ def truncate(s, max)
146
+ return s if s.length <= max
147
+ s[0, max] + "...[truncated]"
148
+ end
107
149
 
108
- if block
109
- @tracer.in_span(name.to_s, attributes: span_attrs) do |s|
110
- block.call(s)
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
111
155
  end
112
- else
113
- @tracer.in_span(name.to_s, attributes: span_attrs) { |_s| }
156
+ @queue << { endpoint: endpoint, payload: payload.dup.freeze }
114
157
  end
115
158
  end
116
159
 
117
- def capture_error(exception, context = nil)
118
- exception = Exception.new(exception.to_s) unless exception.is_a?(Exception)
119
-
120
- attrs = {}
121
- context&.each { |k, v| attrs[k.to_s] = v.to_s }
122
-
123
- @tracer.in_span("error", attributes: attrs) do |s|
124
- s.record_exception(exception)
125
- s.status = OpenTelemetry::Trace::Status.error(exception.message.to_s)
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
126
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
127
175
  end
128
176
 
129
- alias_method :capture_exception, :capture_error
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])
130
185
 
131
- def shutdown
132
- @tracer_provider.shutdown if @tracer_provider.respond_to?(:shutdown)
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
133
206
  end
134
207
  end
135
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
@@ -3,58 +3,84 @@ module ObtraceSDK
3
3
  def initialize(app, client)
4
4
  @app = app
5
5
  @client = client
6
- @tracer = client.tracer
7
6
  end
8
7
 
9
8
  def call(env)
10
- span_name = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
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)
11
13
 
12
- extracted_context = extract_context(env)
14
+ status = nil
15
+ error = nil
13
16
 
14
- span_attrs = {
15
- "http.method" => env["REQUEST_METHOD"].to_s,
16
- "http.target" => env["PATH_INFO"].to_s,
17
- "http.host" => env["HTTP_HOST"].to_s,
18
- "http.scheme" => (env["rack.url_scheme"] || "http").to_s,
19
- "http.user_agent" => env["HTTP_USER_AGENT"].to_s,
20
- "net.peer.ip" => (env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"]).to_s
21
- }
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
22
27
 
23
- if env["QUERY_STRING"] && !env["QUERY_STRING"].empty?
24
- span_attrs["http.query"] = env["QUERY_STRING"]
25
- end
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
26
44
 
27
- OpenTelemetry::Context.with_current(extracted_context) do
28
- @tracer.in_span(span_name, attributes: span_attrs, kind: :server) do |s|
29
- begin
30
- status, headers, body = @app.call(env)
31
- s.set_attribute("http.status_code", status.to_i)
32
- if status.to_i >= 500
33
- s.status = OpenTelemetry::Trace::Status.error("HTTP #{status}")
34
- end
35
- [status, headers, body]
36
- rescue => e
37
- s.record_exception(e)
38
- s.status = OpenTelemetry::Trace::Status.error(e.message.to_s)
39
- s.set_attribute("http.status_code", 500)
40
- raise
41
- end
45
+ if env["QUERY_STRING"] && !env["QUERY_STRING"].empty?
46
+ attrs["http.query"] = env["QUERY_STRING"]
42
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
+ )
43
73
  end
44
74
  end
45
75
 
46
76
  private
47
77
 
48
- def extract_context(env)
78
+ def extract_trace_id(env)
49
79
  traceparent = env["HTTP_TRACEPARENT"]
50
- return OpenTelemetry::Context.current unless traceparent
51
-
52
- OpenTelemetry.propagation.extract(
53
- env,
54
- getter: OpenTelemetry::Common::Propagation.rack_env_getter
55
- )
56
- rescue
57
- OpenTelemetry::Context.current
80
+ return nil unless traceparent
81
+ parts = traceparent.split("-")
82
+ return nil unless parts.length >= 3
83
+ parts[1]
58
84
  end
59
85
  end
60
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
@@ -2,10 +2,10 @@ require "obtrace_sdk"
2
2
 
3
3
  module ObtraceSDK
4
4
  class Railtie < ::Rails::Railtie
5
- config.after_initialize do |app|
6
- api_key = Railtie.credentials_fetch("obtrace_api_key") || ENV["OBTRACE_API_KEY"]
7
- ingest_url = Railtie.credentials_fetch("obtrace_ingest_url") || ENV["OBTRACE_INGEST_BASE_URL"] || "https://inject.obtrace.ai"
8
- service_name = Railtie.credentials_fetch("obtrace_service_name") || ENV["OBTRACE_SERVICE_NAME"] || ::Rails.application.class.module_parent_name.underscore rescue "rails-app"
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
9
 
10
10
  next unless api_key
11
11
 
@@ -14,9 +14,9 @@ module ObtraceSDK
14
14
  ingest_base_url: ingest_url,
15
15
  service_name: service_name,
16
16
  env: ::Rails.env.to_s,
17
- tenant_id: Railtie.credentials_fetch("obtrace_tenant_id") || ENV["OBTRACE_TENANT_ID"],
18
- project_id: Railtie.credentials_fetch("obtrace_project_id") || ENV["OBTRACE_PROJECT_ID"],
19
- app_id: Railtie.credentials_fetch("obtrace_app_id") || ENV["OBTRACE_APP_ID"],
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
20
  debug: ENV["OBTRACE_DEBUG"] == "true"
21
21
  )
22
22
 
@@ -26,7 +26,7 @@ module ObtraceSDK
26
26
  app.middleware.insert(0, ObtraceSDK::Middleware, client)
27
27
  end
28
28
 
29
- def self.Railtie.credentials_fetch(key)
29
+ def credentials_fetch(key)
30
30
  ::Rails.application.credentials.send(key) rescue nil
31
31
  end
32
32
  end
@@ -1,10 +1,11 @@
1
1
  module ObtraceSDK
2
2
  class Config
3
3
  attr_accessor :api_key, :ingest_base_url, :tenant_id, :project_id, :app_id, :env
4
- attr_accessor :service_name, :service_version, :request_timeout_sec
4
+ attr_accessor :service_name, :service_version, :max_queue_size, :request_timeout_sec
5
5
  attr_accessor :default_headers, :debug, :validate_semantic_metrics
6
+ attr_accessor :auto_instrument_http, :auto_capture_logs
6
7
 
7
- 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", request_timeout_sec: 5, default_headers: {}, validate_semantic_metrics: false, debug: false)
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)
8
9
  @api_key = api_key
9
10
  @ingest_base_url = ingest_base_url
10
11
  @tenant_id = tenant_id
@@ -13,10 +14,13 @@ module ObtraceSDK
13
14
  @env = env
14
15
  @service_name = service_name
15
16
  @service_version = service_version
17
+ @max_queue_size = max_queue_size
16
18
  @request_timeout_sec = request_timeout_sec
17
19
  @default_headers = default_headers
18
20
  @validate_semantic_metrics = validate_semantic_metrics
19
21
  @debug = debug
22
+ @auto_instrument_http = auto_instrument_http
23
+ @auto_capture_logs = auto_capture_logs
20
24
  end
21
25
  end
22
26
  end
@@ -1,3 +1,3 @@
1
1
  module ObtraceSDK
2
- VERSION = "1.0.1"
2
+ VERSION = "1.0.2"
3
3
  end
data/lib/obtrace_sdk.rb CHANGED
@@ -1,6 +1,9 @@
1
- require_relative "obtrace_sdk/version"
2
1
  require_relative "obtrace_sdk/types"
3
- require_relative "obtrace_sdk/semantic_metrics"
4
- require_relative "obtrace_sdk/otel_setup"
5
- require_relative "obtrace_sdk/client"
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
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 CHANGED
@@ -1,79 +1,23 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: obtrace-sdk-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obtrace
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-29 00:00:00.000000000 Z
11
+ date: 2026-03-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: opentelemetry-sdk
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '1.3'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '1.3'
27
- - !ruby/object:Gem::Dependency
28
- name: opentelemetry-api
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '1.3'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '1.3'
41
- - !ruby/object:Gem::Dependency
42
- name: opentelemetry-exporter-otlp
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '0.26'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '0.26'
55
- - !ruby/object:Gem::Dependency
56
- name: opentelemetry-instrumentation-net_http
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: opentelemetry-instrumentation-rack
14
+ name: net-http
71
15
  requirement: !ruby/object:Gem::Requirement
72
16
  requirements:
73
17
  - - ">="
74
18
  - !ruby/object:Gem::Version
75
19
  version: '0'
76
- type: :development
20
+ type: :runtime
77
21
  prerelease: false
78
22
  version_requirements: !ruby/object:Gem::Requirement
79
23
  requirements:
@@ -81,13 +25,13 @@ dependencies:
81
25
  - !ruby/object:Gem::Version
82
26
  version: '0'
83
27
  - !ruby/object:Gem::Dependency
84
- name: opentelemetry-instrumentation-rails
28
+ name: json
85
29
  requirement: !ruby/object:Gem::Requirement
86
30
  requirements:
87
31
  - - ">="
88
32
  - !ruby/object:Gem::Version
89
33
  version: '0'
90
- type: :development
34
+ type: :runtime
91
35
  prerelease: false
92
36
  version_requirements: !ruby/object:Gem::Requirement
93
37
  requirements:
@@ -106,8 +50,12 @@ files:
106
50
  - README.md
107
51
  - lib/obtrace_sdk.rb
108
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
109
57
  - lib/obtrace_sdk/middleware.rb
110
- - lib/obtrace_sdk/otel_setup.rb
58
+ - lib/obtrace_sdk/otlp.rb
111
59
  - lib/obtrace_sdk/rails.rb
112
60
  - lib/obtrace_sdk/semantic_metrics.rb
113
61
  - lib/obtrace_sdk/types.rb
@@ -1,71 +0,0 @@
1
- require "opentelemetry/sdk"
2
- require "opentelemetry-exporter-otlp"
3
-
4
- module ObtraceSDK
5
- module OtelSetup
6
- module_function
7
-
8
- def configure(cfg)
9
- endpoint = "#{cfg.ingest_base_url.to_s.sub(%r{/$}, "")}/otlp/v1/traces"
10
-
11
- exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
12
- endpoint: endpoint,
13
- headers: {
14
- "Authorization" => "Bearer #{cfg.api_key}"
15
- }.merge(cfg.default_headers || {}),
16
- timeout: cfg.request_timeout_sec
17
- )
18
-
19
- resource_attrs = {
20
- "service.name" => cfg.service_name,
21
- "service.version" => cfg.service_version,
22
- "deployment.environment" => cfg.env || "dev",
23
- "runtime.name" => "ruby"
24
- }
25
- resource_attrs["obtrace.tenant_id"] = cfg.tenant_id if cfg.tenant_id
26
- resource_attrs["obtrace.project_id"] = cfg.project_id if cfg.project_id
27
- resource_attrs["obtrace.app_id"] = cfg.app_id if cfg.app_id
28
- resource_attrs["obtrace.env"] = cfg.env if cfg.env
29
-
30
- OpenTelemetry::SDK.configure do |c|
31
- c.resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attrs)
32
- c.add_span_processor(
33
- OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
34
- )
35
- auto_detect_instrumentations(c)
36
- end
37
-
38
- OpenTelemetry.propagation = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator
39
-
40
- OpenTelemetry.tracer_provider
41
- end
42
-
43
- def auto_detect_instrumentations(config)
44
- instrumentations = [
45
- ["OpenTelemetry::Instrumentation::Net::HTTP", "opentelemetry-instrumentation-net_http"],
46
- ["OpenTelemetry::Instrumentation::Rack", "opentelemetry-instrumentation-rack"],
47
- ["OpenTelemetry::Instrumentation::Rails", "opentelemetry-instrumentation-rails"],
48
- ["OpenTelemetry::Instrumentation::PG", "opentelemetry-instrumentation-pg"],
49
- ["OpenTelemetry::Instrumentation::Redis", "opentelemetry-instrumentation-redis"],
50
- ["OpenTelemetry::Instrumentation::Sidekiq", "opentelemetry-instrumentation-sidekiq"],
51
- ["OpenTelemetry::Instrumentation::Faraday", "opentelemetry-instrumentation-faraday"],
52
- ["OpenTelemetry::Instrumentation::ActiveRecord", "opentelemetry-instrumentation-active_record"]
53
- ]
54
-
55
- threads = instrumentations.map do |class_name, gem_name|
56
- Thread.new(class_name, gem_name) do |cn, gn|
57
- begin
58
- require gn.gsub("-", "/")
59
- rescue LoadError
60
- end
61
- end
62
- end
63
- threads.each(&:join)
64
-
65
- instrumentations.each do |class_name, gem_name|
66
- klass = Object.const_get(class_name) rescue next
67
- config.use(class_name) if klass
68
- end
69
- end
70
- end
71
- end