octri 1.0.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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +131 -0
  4. data/lib/octri/rack.rb +43 -0
  5. data/lib/octri.rb +413 -0
  6. metadata +48 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b7710dbc38e8c6678e6e5a38456e0dfdf26ce8098b580e5cad6a00ed7ef210e2
4
+ data.tar.gz: b3496fdae687f5e386361fa122bf61d80af342a2d22a6cc239cccbc2c74fcb9c
5
+ SHA512:
6
+ metadata.gz: cd2be05e16cd704c0e1118dbf477b632e764c3cc5f770f3ce6fbfb821b72d67a97ecf912b2d12ab1e15239fa247ed15947d61e830c1da0d5c7d32abd23f66893
7
+ data.tar.gz: 6d0783dd74030d6657e45b7c507add769195eeab6d07bf9024e4b2c79e3f4ab9dbc5b58d1228d2cfc38bc2a16790f3780a8b8579729ae46713704f44dcef3556
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Octri
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,131 @@
1
+ # octri (Ruby)
2
+
3
+ **Error and performance monitoring for Ruby backends.** Report errors out of Rack
4
+ or Rails with original-source context per stack frame, time every request and any
5
+ sub-span you open into a waterfall, and join each server error to the client SDK
6
+ error for the same request through the W3C `traceparent` header. In the
7
+ dashboard you see the full client → server stack under one trace.
8
+
9
+ Octri turns an OpenAPI spec into a documentation site, client SDKs for ten
10
+ languages, an MCP server your AI assistant can call, and monitoring for the
11
+ API behind them. This gem is the Ruby monitoring runtime, and it works on its
12
+ own: a generated Octri API SDK is not required. See
13
+ [octri.dev/monitoring](https://octri.dev/monitoring).
14
+
15
+ Ruby 2.7 or newer. Standard library only. The Ruby sibling of
16
+ [`@octri/node`](https://github.com/octridev/octri-node).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ gem install octri
22
+ ```
23
+
24
+ ## Setup (Rack / Rails)
25
+
26
+ ```ruby
27
+ require "octri"
28
+ require "octri/rack"
29
+
30
+ Octri.init(
31
+ url: "https://monitoring.example.com",
32
+ token: ENV["OCTRI_TOKEN"],
33
+ environment: "<your project id>",
34
+ release: ENV["GIT_SHA"] # optional
35
+ )
36
+
37
+ # config.ru / Rails middleware stack:
38
+ use Octri::Rack
39
+ ```
40
+
41
+ Hosted users can copy the project-scoped URL, token, and environment from the
42
+ Monitoring connection settings (or its API). Omit `token:` only when pointing
43
+ at an open self-hosted ingest endpoint. Every request carries an idempotency key.
44
+
45
+ ## Standalone events
46
+
47
+ No generated API SDK is required to send your own events:
48
+
49
+ ```ruby
50
+ Octri.capture_event(
51
+ "checkout.completed",
52
+ user: { id: customer.id },
53
+ tags: { region: "eu-west", plan: "growth" },
54
+ context: { order_id: order.id, total: order.total }
55
+ )
56
+ ```
57
+
58
+ Delivery is best-effort and runs on a background thread. Pass `event_id:` to
59
+ make a retried delivery idempotent.
60
+
61
+ `Octri::Rack` captures any exception raised by the app (linked to the request's
62
+ trace) and re-raises, and times the request as a server span.
63
+
64
+ ## Automatic instrumentation
65
+
66
+ ```ruby
67
+ Octri.auto_instrument # traces outbound Net::HTTP calls
68
+ Octri.instrument(PG::Connection, [:exec], op: "db") # your DB client / util class, once
69
+ Octri.instrument(cache, [:get, :set], op: "cache")
70
+ ```
71
+
72
+ Every instrumented call (and every outbound HTTP request) becomes a sub-span
73
+ under the current request — no per-call code. Calls to your monitoring backend
74
+ are never traced (no feedback loop).
75
+
76
+ ## Sub-spans (where time goes)
77
+
78
+ ```ruby
79
+ Octri.span("orders.list", op: "db") do
80
+ Order.where(status: "open").to_a
81
+ end
82
+
83
+ # or manual control:
84
+ s = Octri.start_span("cache.get", op: "cache")
85
+ value = cache.read(key)
86
+ s.finish
87
+ ```
88
+
89
+ `op` ("db", "cache", "http", …) color-codes the waterfall; nested `Octri.span`
90
+ calls nest correctly.
91
+
92
+ ## Manual error capture
93
+
94
+ ```ruby
95
+ begin
96
+ risky!
97
+ rescue => e
98
+ Octri.capture_error(e)
99
+ raise
100
+ end
101
+ ```
102
+
103
+ ---
104
+
105
+ ## The rest of Octri
106
+
107
+ | Product | What it does |
108
+ |---|---|
109
+ | [API Studio](https://octri.dev/api-studio) | Your OpenAPI spec becomes a hosted documentation site with a live request playground, editable page by page. |
110
+ | [SDK Studio](https://octri.dev/sdk-studio) | The same spec becomes client libraries for ten languages, versioned and released together. |
111
+ | [MCP](https://octri.dev/mcp) | Your endpoints and docs become tools an AI assistant can call, generated from the same spec. |
112
+ | [Monitoring](https://octri.dev/monitoring) | Errors, traces, uptime and releases for the API, joined to the SDK calls that reached it. |
113
+
114
+ ### Monitoring runtimes
115
+
116
+ [Node](https://github.com/octridev/octri-node) ·
117
+ [Python](https://github.com/octridev/octri-python) ·
118
+ [Go](https://github.com/octridev/octri-go) ·
119
+ [Ruby](https://github.com/octridev/octri-ruby) ·
120
+ [Rust](https://github.com/octridev/octri-rust) ·
121
+ [PHP](https://github.com/octridev/octri-php) ·
122
+ [Java](https://github.com/octridev/octri-java) ·
123
+ [Kotlin](https://github.com/octridev/octri-kotlin) ·
124
+ [Swift](https://github.com/octridev/octri-swift) ·
125
+ [Dart](https://github.com/octridev/octri-dart)
126
+
127
+ [Documentation](https://docs.octri.dev/docs) ·
128
+ [Pricing](https://octri.dev/pricing) ·
129
+ [Changelog](https://docs.octri.dev/changelog)
130
+
131
+ MIT licensed.
data/lib/octri/rack.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octri"
4
+
5
+ module Octri
6
+ # Rack middleware: times each request as a server span (a child of the client
7
+ # SDK span via the incoming `traceparent`) and reports any raised exception as
8
+ # an error linked to the same trace, then re-raises. Sub-spans opened with
9
+ # Octri.span / Octri.start_span during the request nest under it.
10
+ #
11
+ # use Octri::Rack
12
+ class Rack
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ trace = Octri.trace_from_header(env["HTTP_TRACEPARENT"])
19
+ span_id = Octri.new_span_id
20
+ start = Octri.now_iso
21
+ prev = Octri.set_current_span(trace.trace_id, span_id)
22
+ method = env["REQUEST_METHOD"]
23
+ path = env["PATH_INFO"]
24
+ status = 500
25
+
26
+ begin
27
+ result = @app.call(env)
28
+ status = result[0]
29
+ result
30
+ rescue Exception => e # rubocop:disable Lint/RescueException
31
+ Octri.capture_error(e, trace: trace, method: method, path: path, status_code: 500)
32
+ raise
33
+ ensure
34
+ Octri.reset_current_span(prev)
35
+ Octri.capture_span(
36
+ trace_id: trace.trace_id, span_id: span_id, parent_span_id: trace.parent_span_id,
37
+ name: "#{method} #{path}", service: "server", start_time: start,
38
+ end_time: Octri.now_iso, status: status.to_i >= 500 ? "error" : "ok"
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/octri.rb ADDED
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Octri — server-side error monitoring for Ruby backends.
4
+ #
5
+ # Add it to your live API; it reports backend errors to your Octri monitoring
6
+ # project (with original-source context per stack frame) and links each one to
7
+ # the client SDK error for the same request via the W3C `traceparent` header —
8
+ # so the dashboard shows the full client -> server stack under one trace. It also
9
+ # times requests (and any sub-spans you open) into the request waterfall.
10
+ #
11
+ # require "octri"
12
+ # require "octri/rack"
13
+ # Octri.init(url: "https://monitoring.example.com", token: ENV["OCTRI_TOKEN"], environment: "<project id>")
14
+ # use Octri::Rack
15
+
16
+ require "securerandom"
17
+ require "net/http"
18
+ require "uri"
19
+ require "json"
20
+ require "time"
21
+
22
+ module Octri
23
+ Config = Struct.new(:url, :token, :environment, :release)
24
+ Trace = Struct.new(:trace_id, :parent_span_id)
25
+
26
+ CONTEXT_LINES = 5
27
+ REQUEST_TIMEOUT_SECONDS = 5
28
+ MAX_IDEMPOTENCY_KEY_LENGTH = 256
29
+ TRACEPARENT_RE = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}\z/i.freeze
30
+
31
+ class << self
32
+ # Configure the reporter. Call once at startup before mounting the middleware.
33
+ def init(url:, token: nil, environment:, release: nil)
34
+ @config = Config.new(url.sub(%r{/+\z}, ""), token, environment, release)
35
+ @source_cache = {}
36
+ @config
37
+ end
38
+
39
+ attr_reader :config
40
+
41
+ def new_span_id
42
+ SecureRandom.hex(8)
43
+ end
44
+
45
+ # ── Trace context (W3C) ──────────────────────────────────────────────────
46
+ def trace_from_header(traceparent)
47
+ if traceparent && (m = traceparent.to_s.strip.match(TRACEPARENT_RE))
48
+ unless all_zeros?(m[1]) || all_zeros?(m[2])
49
+ return Trace.new(m[1].downcase, m[2].downcase)
50
+ end
51
+ Trace.new(SecureRandom.hex(16), nil)
52
+ else
53
+ Trace.new(SecureRandom.hex(16), nil)
54
+ end
55
+ end
56
+
57
+ # ── Sub-span context (thread-local) ───────────────────────────────────────
58
+ def current_span
59
+ Thread.current[:octri_span]
60
+ end
61
+
62
+ def set_current_span(trace_id, span_id)
63
+ prev = Thread.current[:octri_span]
64
+ Thread.current[:octri_span] = { trace_id: trace_id, span_id: span_id }
65
+ prev
66
+ end
67
+
68
+ def reset_current_span(prev)
69
+ Thread.current[:octri_span] = prev
70
+ end
71
+
72
+ # Open a sub-span under the active request span; call #finish when done.
73
+ # Returns a no-op handle outside a request or before init.
74
+ def start_span(name, op: nil)
75
+ ctx = current_span
76
+ return NoopSpan.new if @config.nil? || ctx.nil?
77
+
78
+ ActiveSpan.new(ctx[:trace_id], new_span_id, ctx[:span_id], name, op || "server")
79
+ end
80
+
81
+ # Time a block as a sub-span under the active request span (nests correctly).
82
+ #
83
+ # Octri.span("orders.list", op: "db") { db.query(sql) }
84
+ def span(name, op: nil)
85
+ ctx = current_span
86
+ return yield if @config.nil? || ctx.nil?
87
+
88
+ span_id = new_span_id
89
+ start = now_iso
90
+ prev = set_current_span(ctx[:trace_id], span_id)
91
+ status = "ok"
92
+ begin
93
+ yield
94
+ rescue Exception # rubocop:disable Lint/RescueException
95
+ status = "error"
96
+ raise
97
+ ensure
98
+ reset_current_span(prev)
99
+ capture_span(
100
+ trace_id: ctx[:trace_id], span_id: span_id, parent_span_id: ctx[:span_id],
101
+ name: name, service: op || "server", start_time: start, end_time: now_iso, status: status
102
+ )
103
+ end
104
+ end
105
+
106
+ # ── Automatic instrumentation ─────────────────────────────────────────────
107
+ # Wrap the given methods so every call becomes a sub-span. Point it at a DB
108
+ # client, cache, or util class once and all calls are traced without per-call
109
+ # code. `target` may be a Class/Module (wraps instance methods) or an object
110
+ # (wraps its singleton methods).
111
+ #
112
+ # Octri.instrument(PG::Connection, [:exec, :exec_params], op: "db")
113
+ # Octri.instrument(cache, [:get, :set], op: "cache")
114
+ def instrument(target, methods, op: nil, name: nil)
115
+ mod = Module.new
116
+ owner = target.is_a?(Module) ? target : target.singleton_class
117
+ methods.each do |method_name|
118
+ next unless owner.method_defined?(method_name) || owner.private_method_defined?(method_name)
119
+
120
+ mod.define_method(method_name) do |*args, **kwargs, &blk|
121
+ span_name = name ? name.call(method_name, args) : method_name.to_s
122
+ Octri.span(span_name, op: op) { super(*args, **kwargs, &blk) }
123
+ end
124
+ end
125
+ owner.prepend(mod)
126
+ target
127
+ end
128
+
129
+ # Turn on zero-config tracing for outbound HTTP (Net::HTTP). Calls to your
130
+ # monitoring backend are never traced (no feedback loop).
131
+ def auto_instrument(http: true)
132
+ patch_net_http if http
133
+ end
134
+
135
+ # ── Reporting ──────────────────────────────────────────────────────────────
136
+ # Log an application event directly, without a generated Octri API SDK.
137
+ # Delivery is fire-and-forget; event_id may be supplied for idempotency.
138
+ def capture_event(message, level: "info", timestamp: nil, operation_id: nil,
139
+ method: nil, path: nil, status_code: nil, latency_ms: nil,
140
+ attempt: nil, request_id: nil, user: nil, tags: nil,
141
+ context: nil, breadcrumbs: nil, fingerprint: nil, trace: nil,
142
+ span_id: nil, event_id: nil)
143
+ return if @config.nil?
144
+
145
+ resolved_event_id = resolve_event_id(event_id)
146
+ payload = {
147
+ eventId: resolved_event_id,
148
+ timestamp: timestamp || now_iso,
149
+ level: level,
150
+ message: message,
151
+ environment: @config.environment,
152
+ tags: { "octri.origin" => "standalone" }.merge(tags || {})
153
+ }
154
+ payload[:release] = @config.release if @config.release
155
+ payload[:operationId] = operation_id if operation_id
156
+ payload[:method] = method if method
157
+ payload[:path] = path if path
158
+ payload[:statusCode] = status_code unless status_code.nil?
159
+ payload[:latencyMs] = latency_ms unless latency_ms.nil?
160
+ payload[:attempt] = attempt unless attempt.nil?
161
+ payload[:requestId] = request_id if request_id
162
+ payload[:user] = user if user
163
+ payload[:context] = context if context
164
+ payload[:breadcrumbs] = breadcrumbs if breadcrumbs
165
+ payload[:fingerprint] = fingerprint if fingerprint
166
+ payload[:traceId] = trace.trace_id if trace
167
+ payload[:spanId] = span_id if span_id
168
+ post_json("/ingest", payload, idempotency_key: resolved_event_id)
169
+ rescue StandardError
170
+ # Invalid caller data must never affect the host application.
171
+ nil
172
+ end
173
+
174
+ def capture_error(exception, trace: nil, method: nil, path: nil, status_code: nil, level: "error")
175
+ return if @config.nil?
176
+
177
+ tr = trace || trace_from_current
178
+ event_id = SecureRandom.hex(16)
179
+ payload = {
180
+ eventId: event_id,
181
+ timestamp: now_iso,
182
+ level: level,
183
+ environment: @config.environment,
184
+ traceId: tr.trace_id,
185
+ spanId: new_span_id,
186
+ tags: { "octri.origin" => "server" },
187
+ error: {
188
+ name: exception.class.name,
189
+ message: exception.message.to_s,
190
+ frames: build_frames(exception)
191
+ }
192
+ }
193
+ payload[:release] = @config.release if @config.release
194
+ payload[:method] = method if method
195
+ payload[:path] = path if path
196
+ payload[:statusCode] = status_code if status_code
197
+ post_json("/ingest", payload, idempotency_key: event_id)
198
+ rescue StandardError
199
+ # Invalid exception-like objects must not affect the host application.
200
+ nil
201
+ end
202
+
203
+ def capture_span(trace_id:, span_id:, name:, start_time:, parent_span_id: nil,
204
+ service: "server", operation_id: nil, end_time: nil, status: "ok")
205
+ return if @config.nil?
206
+ return unless valid_required_span_value?(trace_id) && valid_required_span_value?(span_id) &&
207
+ valid_required_span_value?(name) && valid_iso_time?(start_time)
208
+ return if end_time && !valid_iso_time?(end_time)
209
+ return if (trace_id.length == 32 && all_zeros?(trace_id)) ||
210
+ (span_id.length == 16 && all_zeros?(span_id))
211
+
212
+ payload = { traceId: trace_id, spanId: span_id, environment: @config.environment,
213
+ name: name, service: service, startTime: start_time, status: status }
214
+ payload[:parentSpanId] = parent_span_id if parent_span_id
215
+ payload[:endTime] = end_time if end_time
216
+ payload[:operationId] = operation_id if operation_id
217
+ post_json("/traces", payload, idempotency_key: "#{trace_id}:#{span_id}")
218
+ rescue StandardError
219
+ # Invalid caller data must never affect the host application.
220
+ nil
221
+ end
222
+
223
+ def now_iso
224
+ Time.now.utc.iso8601(3)
225
+ end
226
+
227
+ # True when host:port is the monitoring backend — used to avoid tracing our
228
+ # own reporting requests (which would recurse).
229
+ def monitoring_endpoint?(host, port)
230
+ return false if @config.nil? || @config.url.nil?
231
+
232
+ uri = URI(@config.url)
233
+ uri.host == host && uri.port == port
234
+ rescue StandardError
235
+ false
236
+ end
237
+
238
+ private
239
+
240
+ def all_zeros?(value)
241
+ value.is_a?(String) && !value.empty? && value.each_char.all? { |char| char == "0" }
242
+ end
243
+
244
+ def safe_header_value?(value)
245
+ value.is_a?(String) && !value.empty? && !value.include?("\r") && !value.include?("\n")
246
+ end
247
+
248
+ def safe_idempotency_key?(value)
249
+ safe_header_value?(value) && value.bytesize <= MAX_IDEMPOTENCY_KEY_LENGTH
250
+ end
251
+
252
+ def resolve_event_id(value)
253
+ candidate = value.is_a?(String) ? value.strip : ""
254
+ safe_idempotency_key?(candidate) ? candidate : SecureRandom.hex(16)
255
+ end
256
+
257
+ def valid_required_span_value?(value)
258
+ value.is_a?(String) && !value.strip.empty?
259
+ end
260
+
261
+ def valid_iso_time?(value)
262
+ return false unless valid_required_span_value?(value)
263
+
264
+ Time.iso8601(value)
265
+ true
266
+ rescue ArgumentError
267
+ false
268
+ end
269
+
270
+ def patch_net_http
271
+ require "net/http"
272
+ return if Net::HTTP.instance_variable_get(:@octri_patched)
273
+
274
+ Net::HTTP.prepend(NetHTTPPatch)
275
+ Net::HTTP.instance_variable_set(:@octri_patched, true)
276
+ end
277
+
278
+ def trace_from_current
279
+ ctx = current_span
280
+ return Trace.new(ctx[:trace_id], ctx[:span_id]) if ctx
281
+
282
+ Trace.new(SecureRandom.hex(16), nil)
283
+ end
284
+
285
+ # An exception's backtrace is innermost-first, matching the dashboard's
286
+ # culprit = top frame.
287
+ def build_frames(exception)
288
+ locations = exception.backtrace_locations
289
+ return [] if locations.nil?
290
+
291
+ locations.map do |loc|
292
+ path = loc.absolute_path || loc.path
293
+ lineno = loc.lineno
294
+ frame = { function: loc.label, filename: path, lineno: lineno, colno: 0, inApp: in_app?(path) }
295
+ lines = read_source(path)
296
+ if lines && lineno >= 1 && lineno <= lines.length
297
+ idx = lineno - 1
298
+ frame[:contextLine] = lines[idx]
299
+ pre = lines[[0, idx - CONTEXT_LINES].max...idx]
300
+ post = lines[(idx + 1)...(idx + 1 + CONTEXT_LINES)]
301
+ frame[:preContext] = pre if pre && !pre.empty?
302
+ frame[:postContext] = post if post && !post.empty?
303
+ end
304
+ frame
305
+ end
306
+ end
307
+
308
+ def in_app?(path)
309
+ return false if path.nil?
310
+
311
+ !path.include?("/gems/") && !path.include?("/ruby/") &&
312
+ !path.start_with?(RbConfig::CONFIG["libdir"].to_s)
313
+ end
314
+
315
+ def read_source(path)
316
+ return nil if path.nil?
317
+
318
+ @source_cache ||= {}
319
+ return @source_cache[path] if @source_cache.key?(path)
320
+
321
+ lines = begin
322
+ File.readlines(path, chomp: true)
323
+ rescue StandardError
324
+ nil
325
+ end
326
+ @source_cache[path] = lines
327
+ lines
328
+ end
329
+
330
+ def post_json(path, payload, idempotency_key:)
331
+ cfg = @config
332
+ return if cfg.nil?
333
+ return unless safe_idempotency_key?(idempotency_key)
334
+ return if cfg.token && cfg.token != "" && !safe_header_value?(cfg.token)
335
+
336
+ begin
337
+ Thread.new do
338
+ body = JSON.generate(payload)
339
+ uri = URI("#{cfg.url}#{path}")
340
+ http = Net::HTTP.new(uri.host, uri.port)
341
+ http.use_ssl = uri.scheme == "https"
342
+ http.open_timeout = REQUEST_TIMEOUT_SECONDS
343
+ http.read_timeout = REQUEST_TIMEOUT_SECONDS
344
+ http.write_timeout = REQUEST_TIMEOUT_SECONDS if http.respond_to?(:write_timeout=)
345
+ req = Net::HTTP::Post.new(uri)
346
+ req["content-type"] = "application/json"
347
+ req["idempotency-key"] = idempotency_key
348
+ req["authorization"] = "Bearer #{cfg.token}" if cfg.token && cfg.token != ""
349
+ req.body = body
350
+ http.request(req)
351
+ rescue StandardError
352
+ # A reporting failure must never affect the app.
353
+ end
354
+ rescue StandardError
355
+ # Thread exhaustion must not make monitoring affect the application.
356
+ nil
357
+ end
358
+ end
359
+ end
360
+
361
+ # Prepended to Net::HTTP by auto_instrument: wraps outbound requests in an
362
+ # `http` span (skipping requests to the monitoring backend, and when there's no
363
+ # active request span).
364
+ module NetHTTPPatch
365
+ def request(req, body = nil, &block)
366
+ cfg = Octri.config
367
+ if cfg.nil? || Octri.current_span.nil? || Octri.monitoring_endpoint?(address, port)
368
+ return super
369
+ end
370
+
371
+ path = req.respond_to?(:path) ? req.path : ""
372
+ method = req.respond_to?(:method) ? req.method : "GET"
373
+ Octri.span("#{method} #{address}:#{port}#{path}", op: "http") { super }
374
+ end
375
+ end
376
+
377
+ # A sub-span handle for manual control; #finish reports it (named `finish`
378
+ # rather than `end`, which is a Ruby keyword).
379
+ class ActiveSpan
380
+ def initialize(trace_id, span_id, parent_span_id, name, service)
381
+ @trace_id = trace_id
382
+ @span_id = span_id
383
+ @parent_span_id = parent_span_id
384
+ @name = name
385
+ @service = service
386
+ @start = Octri.now_iso
387
+ @status = "ok"
388
+ @ended = false
389
+ end
390
+
391
+ def fail
392
+ @status = "error"
393
+ end
394
+
395
+ def finish(status = nil)
396
+ return if @ended
397
+
398
+ @ended = true
399
+ Octri.capture_span(
400
+ trace_id: @trace_id, span_id: @span_id, parent_span_id: @parent_span_id,
401
+ name: @name, service: @service, start_time: @start,
402
+ end_time: Octri.now_iso, status: status || @status
403
+ )
404
+ end
405
+ end
406
+
407
+ # No-op handle returned outside a request.
408
+ class NoopSpan
409
+ def fail; end
410
+
411
+ def finish(_status = nil); end
412
+ end
413
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octri
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Octri
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Reports backend errors to your Octri monitoring project and links them
13
+ to client SDK errors via W3C trace context; times requests and sub-spans into the
14
+ request waterfall.
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/octri.rb
22
+ - lib/octri/rack.rb
23
+ homepage: https://octri.dev
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ homepage_uri: https://octri.dev
28
+ documentation_uri: https://docs.octri.dev/docs
29
+ bug_tracker_uri: https://octri.dev/support
30
+ source_code_uri: https://github.com/octridev/octri-ruby
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.7'
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.6.9
46
+ specification_version: 4
47
+ summary: Server-side error monitoring for Ruby backends (Rack / Rails).
48
+ test_files: []