ditliti 0.1.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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/ditliti/db.rb +99 -0
  4. data/lib/ditliti.rb +297 -0
  5. metadata +48 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '05886e9711c1cbe6a5c8d27fa42a2663ef98d57e04a76f557f16b21b6db59b5c'
4
+ data.tar.gz: f8b4dd43af968bc050a58e370a243e1280434213acc53c80898d2886c0f446c1
5
+ SHA512:
6
+ metadata.gz: 033ec9d232d630eabdd803000d2f5527f4cd63280e0cec0c56ec462eecc8fbc8c352411bb91edf63f38b7b2c6e52422c8c44ed052d5eb5ef7a6dc490caeadcdd
7
+ data.tar.gz: 33f4b8de25f65db24de77362d88e44005be1511a1d0f6982976c262b34a8e7495df503a82cac58546ad76e4ae1fa14477649b6ffc22fbadad116459d2664d0e7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 candrabasic
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/lib/ditliti/db.rb ADDED
@@ -0,0 +1,99 @@
1
+ require_relative "../ditliti"
2
+
3
+ module Ditliti
4
+ # Database query auto-instrumentation.
5
+ #
6
+ # Wraps a query execution with a `db.query` span carrying OpenTelemetry-
7
+ # shaped database attributes. The backend (ingestion-api) parameterizes
8
+ # and sanitizes `db.query.text` server-side and derives
9
+ # `db.query.summary` / `db.operation.name` / `db.collection.name` /
10
+ # `ditliti.query_hash` from it - callers here only need to supply the
11
+ # real SQL text, exactly like every other span already sent by this SDK
12
+ # travels over the same trust boundary to the user's own ingestion-api.
13
+ #
14
+ # This never changes the wrapped call's exception flow: span-recording
15
+ # failures are swallowed, and the original exception (if any) is always
16
+ # re-raised unchanged.
17
+ #
18
+ # Note: this environment has no Ruby runtime available, so this file has
19
+ # been carefully written (mirroring the already-tested Python/PHP/Elixir
20
+ # adapters' generic-wrapper + ORM-notification shape) but has not itself
21
+ # been executed - review before relying on it in production.
22
+ module DbTracing
23
+ module_function
24
+
25
+ def trace_query(client, system:, text:, collection: nil, operation: nil)
26
+ tags = { "db.system.name" => system, "db.query.text" => text }
27
+ tags["db.operation.name"] = operation if operation
28
+ tags["db.collection.name"] = collection if collection
29
+
30
+ span = client.start_span("db.query", tags: tags)
31
+ begin
32
+ result = yield
33
+ count = extract_row_count(result)
34
+ safe_finish(span, count ? { "db.response.returned_rows" => count.to_s } : {})
35
+ result
36
+ rescue => error
37
+ safe_finish(span, { "error.type" => error.class.name })
38
+ raise
39
+ end
40
+ end
41
+
42
+ # Subscribes to ActiveRecord's `sql.active_record` notification - no
43
+ # query wrapping required, works for the query builder, Active Record
44
+ # models, and raw `connection.execute` alike. Requires ActiveSupport to
45
+ # already be loaded by the host application (this SDK has no Rails
46
+ # dependency of its own).
47
+ def subscribe_active_record(client, system: "postgresql")
48
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
49
+ event = ActiveSupport::Notifications::Event.new(*args)
50
+ next if event.payload[:name] == "SCHEMA" # migrations/introspection queries, not app queries
51
+
52
+ record_completed_span(
53
+ client,
54
+ system: system,
55
+ text: event.payload[:sql],
56
+ duration_ms: event.duration,
57
+ collection: event.payload[:name]
58
+ )
59
+ end
60
+ end
61
+
62
+ # Records a span whose duration is already known (e.g. from
63
+ # ActiveRecord's notification, which reports a query's elapsed time
64
+ # only after it has already completed) - unlike `trace_query`, there's
65
+ # no separate "finish" step since the operation is already over.
66
+ def record_completed_span(client, system:, text:, duration_ms:, collection: nil, operation: nil, error_type: nil)
67
+ client.start_trace if client.trace.nil?
68
+ parent_span_id = client.trace.span_id
69
+ ctx = client.trace.child
70
+
71
+ tags = { "db.system.name" => system, "db.query.text" => text }
72
+ tags["db.operation.name"] = operation if operation
73
+ tags["db.collection.name"] = collection if collection
74
+ tags["error.type"] = error_type if error_type
75
+
76
+ client.record_span(
77
+ trace_id: ctx.trace_id,
78
+ span_id: ctx.span_id,
79
+ parent_span_id: parent_span_id,
80
+ operation_name: "db.query",
81
+ duration_ms: duration_ms,
82
+ tags: tags
83
+ )
84
+ end
85
+
86
+ def safe_finish(span, extra_tags)
87
+ span.finish(extra_tags: extra_tags)
88
+ rescue StandardError
89
+ # Instrumentation must never break the caller's query path.
90
+ end
91
+
92
+ def extract_row_count(result)
93
+ return result if result.is_a?(Integer) && result >= 0
94
+ return result.length if result.respond_to?(:length)
95
+
96
+ nil
97
+ end
98
+ end
99
+ end
data/lib/ditliti.rb ADDED
@@ -0,0 +1,297 @@
1
+ require "digest"
2
+ require "json"
3
+ require "net/http"
4
+ require "securerandom"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Ditliti
9
+ # A W3C trace-context (https://www.w3.org/TR/trace-context/) carried
10
+ # across service boundaries via the `traceparent` header, so a trace
11
+ # started in one process is continued - not restarted - in the next.
12
+ class TraceContext
13
+ attr_reader :trace_id, :span_id, :sampled
14
+
15
+ def self.generate_trace_id
16
+ SecureRandom.hex(16)
17
+ end
18
+
19
+ def self.generate_span_id
20
+ SecureRandom.hex(8)
21
+ end
22
+
23
+ def self.start
24
+ new(generate_trace_id, generate_span_id, true)
25
+ end
26
+
27
+ def self.from_traceparent(header)
28
+ return nil if header.nil? || header.strip.empty?
29
+
30
+ parts = header.strip.split("-")
31
+ return nil unless parts.length == 4
32
+
33
+ _version, trace_id, span_id, flags = parts
34
+ return nil unless trace_id =~ /\A[0-9a-f]{32}\z/ && trace_id != "0" * 32
35
+ return nil unless span_id =~ /\A[0-9a-f]{16}\z/ && span_id != "0" * 16
36
+
37
+ flags_value = Integer(flags, 16) rescue nil
38
+ return nil if flags_value.nil?
39
+
40
+ new(trace_id, span_id, (flags_value & 1) == 1)
41
+ end
42
+
43
+ def initialize(trace_id, span_id, sampled = true)
44
+ @trace_id = trace_id
45
+ @span_id = span_id
46
+ @sampled = sampled
47
+ end
48
+
49
+ def child
50
+ self.class.new(trace_id, self.class.generate_span_id, sampled)
51
+ end
52
+
53
+ def to_traceparent
54
+ "00-#{trace_id}-#{span_id}-#{sampled ? "01" : "00"}"
55
+ end
56
+ end
57
+
58
+ # Returned by Client#start_span; call #finish when the operation completes
59
+ # to record and send the span.
60
+ class Span
61
+ def initialize(client, operation_name, parent_span_id, ctx, tags)
62
+ @client = client
63
+ @operation_name = operation_name
64
+ @parent_span_id = parent_span_id
65
+ @ctx = ctx
66
+ @tags = tags
67
+ @started_at = Time.now.utc
68
+ @started_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ end
70
+
71
+ def trace_id
72
+ @ctx.trace_id
73
+ end
74
+
75
+ def span_id
76
+ @ctx.span_id
77
+ end
78
+
79
+ def traceparent
80
+ @ctx.to_traceparent
81
+ end
82
+
83
+ def finish(extra_tags: {})
84
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_monotonic) * 1000
85
+ @client.record_span(
86
+ trace_id: @ctx.trace_id,
87
+ span_id: @ctx.span_id,
88
+ parent_span_id: @parent_span_id,
89
+ operation_name: @operation_name,
90
+ duration_ms: duration_ms,
91
+ started_at: @started_at,
92
+ tags: @tags.merge(extra_tags)
93
+ )
94
+ end
95
+ end
96
+
97
+ class Client
98
+ attr_accessor :user, :session_id
99
+ attr_reader :trace
100
+
101
+ def initialize(endpoint:, api_key:, project_id:, release: nil, environment: nil, timeout: 1.5)
102
+ @endpoint = endpoint.sub(%r{/$}, "")
103
+ @api_key = api_key
104
+ @project_id = project_id
105
+ @release = release
106
+ @environment = environment
107
+ @timeout = timeout
108
+ @breadcrumbs = []
109
+ @trace = nil
110
+ end
111
+
112
+ # Start (or continue, if an inbound `traceparent` header is given) the
113
+ # active trace. Downstream calls should read #get_traceparent and
114
+ # forward it as the `traceparent` header of any outgoing HTTP request to
115
+ # propagate the trace into the next service.
116
+ def start_trace(incoming_traceparent = nil)
117
+ parent = TraceContext.from_traceparent(incoming_traceparent)
118
+ @trace = parent ? parent.child : TraceContext.start
119
+ @trace
120
+ end
121
+
122
+ def get_traceparent
123
+ @trace&.to_traceparent
124
+ end
125
+
126
+ # Start a child span under the active trace (starting one implicitly if
127
+ # none is active yet).
128
+ def start_span(operation_name, tags: {})
129
+ start_trace if @trace.nil?
130
+ parent_span_id = @trace.span_id
131
+ ctx = @trace.child
132
+ Span.new(self, operation_name, parent_span_id, ctx, { runtime: "ruby" }.merge(tags))
133
+ end
134
+
135
+ def record_span(trace_id:, span_id:, operation_name:, duration_ms:, parent_span_id: nil, started_at: nil, tags: {})
136
+ base_tags = { runtime: "ruby" }
137
+ base_tags[:environment] = @environment if @environment
138
+ base_tags[:release] = @release if @release
139
+ send_envelope({
140
+ spans: [{
141
+ trace_id: trace_id,
142
+ span_id: span_id,
143
+ parent_span_id: parent_span_id,
144
+ project_id: @project_id,
145
+ operation_name: operation_name,
146
+ start_time: (started_at || Time.now.utc).iso8601(3),
147
+ duration_ms: duration_ms,
148
+ tags: base_tags.merge(tags)
149
+ }]
150
+ })
151
+ end
152
+
153
+ private
154
+
155
+ def current_trace_tags
156
+ @trace ? { trace_id: @trace.trace_id } : {}
157
+ end
158
+
159
+ public
160
+
161
+ def add_breadcrumb(message:, category: nil, level: "info", data: {})
162
+ @breadcrumbs << {
163
+ timestamp: Time.now.utc.iso8601,
164
+ category: category,
165
+ message: message.to_s,
166
+ level: level,
167
+ data: data || {}
168
+ }
169
+ @breadcrumbs.shift while @breadcrumbs.length > 100
170
+ end
171
+
172
+ def capture_exception(error, context: {}, tags: {}, transaction: nil, user_agent: nil)
173
+ payload = {
174
+ id: SecureRandom.uuid,
175
+ project_id: @project_id,
176
+ level: "error",
177
+ message: error.message.to_s,
178
+ exception_type: error.class.name,
179
+ stack_trace: stack_frames(error),
180
+ tags: { runtime: "ruby" }.merge(current_trace_tags).merge(tags || {}),
181
+ context: context || {},
182
+ user_agent: user_agent,
183
+ release: @release,
184
+ environment: @environment,
185
+ transaction: transaction,
186
+ session_id: @session_id,
187
+ breadcrumbs: @breadcrumbs,
188
+ user: @user
189
+ }
190
+ post("/api/v1/ingest", payload, stable_id(payload))
191
+ end
192
+
193
+ def capture_rack_exception(error, env)
194
+ request_method = env["REQUEST_METHOD"]
195
+ path = env["PATH_INFO"] || env["REQUEST_URI"]
196
+ capture_exception(
197
+ error,
198
+ context: {
199
+ framework: "rack",
200
+ method: request_method,
201
+ path: path,
202
+ query: env["QUERY_STRING"]
203
+ },
204
+ tags: { framework: "rack" },
205
+ transaction: "#{request_method || "HTTP"} #{path || "/"}",
206
+ user_agent: env["HTTP_USER_AGENT"]
207
+ )
208
+ end
209
+
210
+ def send_envelope(envelope)
211
+ post("/api/v1/envelope", {
212
+ project_id: @project_id,
213
+ events: envelope.fetch(:events, []),
214
+ spans: envelope.fetch(:spans, []),
215
+ logs: envelope.fetch(:logs, []),
216
+ replays: envelope.fetch(:replays, []),
217
+ profiles: envelope.fetch(:profiles, [])
218
+ })
219
+ end
220
+
221
+ def capture_replay_segment(duration_ms:, session_id: @session_id, segment_index: 0, events: [], tags: {}, user: @user, release: @release, environment: @environment)
222
+ raise ArgumentError, "replay session_id is required" if session_id.nil? || session_id.to_s.empty?
223
+
224
+ send_envelope({
225
+ replays: [{
226
+ project_id: @project_id,
227
+ session_id: session_id,
228
+ started_at: Time.now.utc.iso8601,
229
+ duration_ms: duration_ms,
230
+ segment_index: segment_index,
231
+ events: events || [],
232
+ tags: { runtime: "ruby" }.merge(tags || {}),
233
+ user: user,
234
+ release: release,
235
+ environment: environment
236
+ }]
237
+ })
238
+ end
239
+
240
+ def capture_profile(duration_ms:, trace_id: nil, transaction: nil, platform: "ruby", samples: [], tags: {}, release: @release, environment: @environment)
241
+ send_envelope({
242
+ profiles: [{
243
+ project_id: @project_id,
244
+ trace_id: trace_id || @trace&.trace_id,
245
+ transaction: transaction,
246
+ started_at: Time.now.utc.iso8601,
247
+ duration_ms: duration_ms,
248
+ platform: platform,
249
+ samples: samples || [],
250
+ tags: { runtime: "ruby" }.merge(tags || {}),
251
+ release: release,
252
+ environment: environment
253
+ }]
254
+ })
255
+ end
256
+
257
+ private
258
+
259
+ def post(path, payload, event_id = nil)
260
+ uri = URI("#{@endpoint}#{path}")
261
+ request = Net::HTTP::Post.new(uri)
262
+ request["content-type"] = "application/json"
263
+ request["x-api-key"] = @api_key
264
+ request["x-event-id"] = event_id if event_id
265
+ request.body = JSON.generate(payload)
266
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: @timeout, open_timeout: @timeout) do |http|
267
+ http.request(request)
268
+ end
269
+ raise "ditliti ingestion failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
270
+ end
271
+
272
+ def stack_frames(error)
273
+ Array(error.backtrace_locations).map do |frame|
274
+ { function: frame.label, file: frame.path, line: frame.lineno, column: nil }
275
+ end
276
+ end
277
+
278
+ def stable_id(payload)
279
+ Digest::SHA256.hexdigest("#{payload[:project_id]}|#{payload[:exception_type]}|#{payload[:message]}")
280
+ end
281
+ end
282
+
283
+ class RackMiddleware
284
+ def initialize(app, client)
285
+ @app = app
286
+ @client = client
287
+ end
288
+
289
+ def call(env)
290
+ @client.start_trace(env["HTTP_TRACEPARENT"])
291
+ @app.call(env)
292
+ rescue => error
293
+ @client.capture_rack_exception(error, env)
294
+ raise
295
+ end
296
+ end
297
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ditliti
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - candrabasic
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Error tracking, tracing, replay, and profiling ingestion client for a
14
+ self-hosted Ditliti instance.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - lib/ditliti.rb
22
+ - lib/ditliti/db.rb
23
+ homepage: https://github.com/kangartha/inspectora/tree/main/sdk/ruby
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ source_code_uri: https://github.com/kangartha/inspectora
28
+ bug_tracker_uri: https://github.com/kangartha/inspectora/issues
29
+ post_install_message:
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '3.0'
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 3.5.22
45
+ signing_key:
46
+ specification_version: 4
47
+ summary: Official Ruby SDK for Ditliti
48
+ test_files: []