opentrace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba760f415dab6abc4ce324ec9f63aaaa961d4cd115555fce4b4f11fa718c1327
4
+ data.tar.gz: 2d40538689afa5fafa0ab678d5b81f64a2ee2cccbf8b69143f1f0786422b71fa
5
+ SHA512:
6
+ metadata.gz: c4e5a1c1f97d166b2905f139c9035876a627f0affd604092fead4a64a8795913ab8e4dec1b86c7c2b337ce1fc070c776105e758fb16624fb28e5342bbcf0eb5c
7
+ data.tar.gz: 7f3a034f7fbc791d73201e57a0ac25fe4307a92436873fa0256517887e19a689d11e1d5fd29c9f203894e3847557ebb417f8c9e8d5fff897a1fac7f15b62d4bd
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 OpenTrace
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,175 @@
1
+ # OpenTrace Ruby
2
+
3
+ A thin, safe Ruby client that forwards structured application logs to an [OpenTrace](https://github.com/opentrace/opentrace) server over HTTP.
4
+
5
+ **This gem will never crash or slow down your application.** All network errors are swallowed silently. If the server is unreachable, logs are dropped.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "opentrace"
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ ```ruby
24
+ OpenTrace.configure do |c|
25
+ c.endpoint = "https://opentrace.example.com" # required
26
+ c.api_key = ENV["OPENTRACE_API_KEY"] # required
27
+ c.service = "billing-api" # required
28
+ c.environment = "production" # optional
29
+ c.timeout = 1.0 # optional, seconds (default: 1.0)
30
+ c.enabled = true # optional (default: true)
31
+ end
32
+ ```
33
+
34
+ If any required field (`endpoint`, `api_key`, `service`) is missing or empty, the gem **disables itself automatically**. No errors, no logs sent.
35
+
36
+ ## Usage
37
+
38
+ ### Direct logging
39
+
40
+ ```ruby
41
+ OpenTrace.log("INFO", "User signed in", { user_id: 42, ip: "1.2.3.4" })
42
+
43
+ OpenTrace.log("ERROR", "Payment failed", {
44
+ trace_id: "abc-123",
45
+ user_id: 99,
46
+ exception: {
47
+ class: "Stripe::CardError",
48
+ message: "Your card was declined"
49
+ }
50
+ })
51
+ ```
52
+
53
+ Pass `trace_id` inside metadata and it will be promoted to a top-level field automatically.
54
+
55
+ ### Logger wrapper
56
+
57
+ Wrap any Ruby `Logger` to forward all log output to OpenTrace while keeping the original logger working exactly as before:
58
+
59
+ ```ruby
60
+ require "logger"
61
+
62
+ logger = Logger.new($stdout)
63
+ logger = OpenTrace::Logger.new(logger)
64
+
65
+ logger.info("This goes to STDOUT and to OpenTrace")
66
+ logger.error("So does this")
67
+ ```
68
+
69
+ You can attach default metadata to every log from this logger:
70
+
71
+ ```ruby
72
+ logger = OpenTrace::Logger.new(original_logger, metadata: { component: "worker" })
73
+ ```
74
+
75
+ ### Rails
76
+
77
+ In a Rails app, add an initializer:
78
+
79
+ ```ruby
80
+ # config/initializers/opentrace.rb
81
+ OpenTrace.configure do |c|
82
+ c.endpoint = ENV["OPENTRACE_ENDPOINT"]
83
+ c.api_key = ENV["OPENTRACE_API_KEY"]
84
+ c.service = "my-rails-app"
85
+ c.environment = Rails.env
86
+ end
87
+ ```
88
+
89
+ The gem auto-detects Rails and will:
90
+
91
+ - Wrap `Rails.logger` so all log output is forwarded to OpenTrace
92
+ - Subscribe to `process_action.action_controller` notifications to capture:
93
+ - `request_id`
94
+ - `controller` and `action`
95
+ - `method`, `path`, `status`, `duration_ms`
96
+ - `user_id` (if your controller responds to `current_user`)
97
+
98
+ Requests that return 5xx status codes are logged as `ERROR`, everything else as `INFO`.
99
+
100
+ ### TaggedLogging
101
+
102
+ If your wrapped logger uses `ActiveSupport::TaggedLogging`, tags are preserved and injected into the metadata:
103
+
104
+ ```ruby
105
+ Rails.logger.tagged("RequestID-123") do
106
+ Rails.logger.info("Processing request")
107
+ # metadata will include: { tags: ["RequestID-123"] }
108
+ end
109
+ ```
110
+
111
+ ## Runtime controls
112
+
113
+ ```ruby
114
+ OpenTrace.enabled? # check if logging is active
115
+ OpenTrace.disable! # turn off (logs are silently dropped)
116
+ OpenTrace.enable! # turn back on
117
+ ```
118
+
119
+ ## Graceful shutdown
120
+
121
+ If your app needs a clean shutdown (e.g. a Sidekiq worker), drain the queue before exiting:
122
+
123
+ ```ruby
124
+ OpenTrace.shutdown(timeout: 5)
125
+ ```
126
+
127
+ This gives the background thread up to 5 seconds to send any remaining queued logs.
128
+
129
+ ## How it works
130
+
131
+ - Logs are serialized to JSON and pushed onto an in-memory queue
132
+ - A single background thread reads from the queue and sends each payload via `POST /api/logs`
133
+ - The thread is started lazily on the first log call -- no threads are created at boot
134
+ - If the queue exceeds 1,000 items, new logs are dropped (oldest are preserved)
135
+ - Payloads larger than 32 KB are dropped
136
+ - All network errors (timeouts, connection refused, DNS failures) are swallowed silently
137
+ - The HTTP timeout defaults to 1 second
138
+
139
+ ## Log payload format
140
+
141
+ Each log is sent as a JSON object:
142
+
143
+ ```json
144
+ {
145
+ "timestamp": "2026-02-08T12:41:00.000000Z",
146
+ "level": "ERROR",
147
+ "service": "billing-api",
148
+ "environment": "production",
149
+ "trace_id": "abc-123",
150
+ "message": "PG::UniqueViolation",
151
+ "metadata": {
152
+ "user_id": 42,
153
+ "request_id": "req-456"
154
+ }
155
+ }
156
+ ```
157
+
158
+ | Field | Type | Required |
159
+ |---------------|--------|----------|
160
+ | `timestamp` | string | yes |
161
+ | `level` | string | yes |
162
+ | `message` | string | yes |
163
+ | `service` | string | no |
164
+ | `environment` | string | no |
165
+ | `trace_id` | string | no |
166
+ | `metadata` | object | no |
167
+
168
+ ## Requirements
169
+
170
+ - Ruby 3.0+
171
+ - Rails 6+ (optional, auto-detected)
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module OpenTrace
8
+ class Client
9
+ MAX_QUEUE_SIZE = 1000
10
+ PAYLOAD_MAX_BYTES = 32_768 # 32 KB
11
+ POLL_INTERVAL = 0.05 # 50ms
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ @queue = Thread::Queue.new
16
+ @mutex = Mutex.new
17
+ @thread = nil
18
+ end
19
+
20
+ def enqueue(payload)
21
+ return unless @config.enabled?
22
+
23
+ # Drop newest if queue is full
24
+ return if @queue.size >= MAX_QUEUE_SIZE
25
+
26
+ @queue.push(payload)
27
+ ensure_thread_running
28
+ end
29
+
30
+ def shutdown(timeout: 5)
31
+ @queue.close
32
+ @thread&.join(timeout)
33
+ end
34
+
35
+ private
36
+
37
+ def ensure_thread_running
38
+ return if @thread&.alive?
39
+
40
+ @mutex.synchronize do
41
+ return if @thread&.alive?
42
+
43
+ @thread = Thread.new { dispatch_loop }
44
+ @thread.abort_on_exception = false
45
+ @thread.report_on_exception = false
46
+ end
47
+ end
48
+
49
+ def dispatch_loop
50
+ uri = URI.join(@config.endpoint.chomp("/") + "/", "api/logs")
51
+
52
+ loop do
53
+ batch = drain_queue
54
+ break if batch.nil?
55
+ next if batch.empty?
56
+
57
+ send_batch(uri, batch)
58
+ end
59
+ rescue Exception # rubocop:disable Lint/RescueException
60
+ # Swallow all errors including thread kill
61
+ end
62
+
63
+ def drain_queue
64
+ batch = []
65
+ deadline = Time.now + @config.flush_interval
66
+
67
+ loop do
68
+ if batch.empty?
69
+ # Block until first item arrives or timeout
70
+ item = pop_with_timeout(deadline - Time.now)
71
+ return nil if item.nil? && @queue.closed?
72
+ batch << item if item
73
+ else
74
+ # Non-blocking drain up to batch_size
75
+ while batch.size < @config.batch_size
76
+ begin
77
+ item = @queue.pop(true) # non_block = true
78
+ batch << item
79
+ rescue ThreadError
80
+ break # queue empty
81
+ end
82
+ end
83
+ end
84
+
85
+ break if batch.size >= @config.batch_size
86
+ break if Time.now >= deadline && !batch.empty?
87
+ break if @queue.closed?
88
+ end
89
+
90
+ batch
91
+ end
92
+
93
+ def pop_with_timeout(timeout)
94
+ deadline = Time.now + [timeout, 0].max
95
+ loop do
96
+ begin
97
+ return @queue.pop(true)
98
+ rescue ThreadError
99
+ return nil if Time.now >= deadline || @queue.closed?
100
+ sleep(POLL_INTERVAL)
101
+ end
102
+ end
103
+ rescue ClosedQueueError
104
+ nil
105
+ end
106
+
107
+ def send_batch(uri, batch)
108
+ # Apply per-payload truncation
109
+ batch = batch.map { |p| fit_payload(p) }.compact
110
+ return if batch.empty?
111
+
112
+ json = JSON.generate(batch)
113
+
114
+ # If entire batch exceeds limit, split and retry
115
+ if json.bytesize > PAYLOAD_MAX_BYTES
116
+ mid = batch.size / 2
117
+ send_batch(uri, batch[0...mid]) if mid > 0
118
+ send_batch(uri, batch[mid..]) if mid < batch.size
119
+ return
120
+ end
121
+
122
+ http = build_http(uri)
123
+ request = Net::HTTP::Post.new(uri.request_uri)
124
+ request["Authorization"] = "Bearer #{@config.api_key}"
125
+ request["Content-Type"] = "application/json"
126
+ request["User-Agent"] = "opentrace-ruby/#{OpenTrace::VERSION}"
127
+ request.body = json
128
+
129
+ http.request(request)
130
+ rescue StandardError
131
+ # Swallow all network errors silently
132
+ end
133
+
134
+ def build_http(uri)
135
+ http = Net::HTTP.new(uri.host, uri.port)
136
+ http.use_ssl = (uri.scheme == "https")
137
+ http.open_timeout = @config.timeout
138
+ http.read_timeout = @config.timeout
139
+ http.write_timeout = @config.timeout
140
+ http
141
+ end
142
+
143
+ def fit_payload(payload)
144
+ json = JSON.generate(payload)
145
+ if json.bytesize > PAYLOAD_MAX_BYTES
146
+ payload = truncate_payload(payload)
147
+ json = JSON.generate(payload)
148
+ return nil if json.bytesize > PAYLOAD_MAX_BYTES
149
+ end
150
+ payload
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def truncate_payload(payload)
156
+ meta = payload[:metadata]&.dup || {}
157
+
158
+ # Truncation priority: remove largest optional fields first
159
+ meta.delete(:backtrace)
160
+ meta.delete(:params)
161
+ meta.delete(:job_arguments)
162
+ meta[:sql] = meta[:sql][0, 200] + "..." if meta[:sql].is_a?(String) && meta[:sql].length > 200
163
+ meta[:exception_message] = meta[:exception_message][0, 200] + "..." if meta[:exception_message].is_a?(String) && meta[:exception_message].length > 200
164
+
165
+ payload.merge(metadata: meta)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ class Config
5
+ REQUIRED_FIELDS = %i[endpoint api_key service].freeze
6
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
7
+
8
+ attr_accessor :endpoint, :api_key, :service, :environment, :timeout, :enabled,
9
+ :context, :min_level, :hostname, :pid, :git_sha,
10
+ :batch_size, :flush_interval,
11
+ :sql_logging, :sql_duration_threshold_ms
12
+
13
+ def initialize
14
+ @endpoint = nil
15
+ @api_key = nil
16
+ @service = nil
17
+ @environment = nil
18
+ @timeout = 1.0
19
+ @enabled = true
20
+ @context = nil # nil | Hash | Proc
21
+ @min_level = :debug # send everything by default
22
+ @hostname = nil
23
+ @pid = nil
24
+ @git_sha = nil
25
+ @batch_size = 50
26
+ @flush_interval = 5.0
27
+ @sql_logging = true
28
+ @sql_duration_threshold_ms = 0.0
29
+ end
30
+
31
+ def valid?
32
+ REQUIRED_FIELDS.all? { |f| value = send(f); value.is_a?(String) && !value.empty? }
33
+ end
34
+
35
+ def enabled?
36
+ @enabled && valid?
37
+ end
38
+
39
+ def min_level_value
40
+ LEVELS[min_level.to_s.downcase.to_sym] || 0
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module OpenTrace
6
+ # Minimal Logger-compatible class that forwards log messages to OpenTrace.
7
+ # Designed to be used as a broadcast target with Rails 7.1+ BroadcastLogger.
8
+ # Does NOT wrap another logger — its only job is to forward to OpenTrace.
9
+ class LogForwarder < ::Logger
10
+ SEVERITY_MAP = {
11
+ ::Logger::DEBUG => "DEBUG",
12
+ ::Logger::INFO => "INFO",
13
+ ::Logger::WARN => "WARN",
14
+ ::Logger::ERROR => "ERROR",
15
+ ::Logger::FATAL => "FATAL",
16
+ ::Logger::UNKNOWN => "UNKNOWN"
17
+ }.freeze
18
+
19
+ def initialize
20
+ super(nil)
21
+ self.level = ::Logger::DEBUG
22
+ end
23
+
24
+ def add(severity, message = nil, progname = nil, &block)
25
+ severity ||= ::Logger::UNKNOWN
26
+ return true if severity < level
27
+
28
+ msg = resolve_message(message, progname, &block)
29
+ return true if msg.nil? || (msg.is_a?(String) && msg.strip.empty?)
30
+
31
+ level_str = SEVERITY_MAP.fetch(severity, "UNKNOWN")
32
+ OpenTrace.log(level_str, msg.to_s)
33
+
34
+ true
35
+ rescue StandardError
36
+ true
37
+ end
38
+
39
+ def close
40
+ # no-op — nothing to close
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_message(message, progname, &block)
46
+ if message.nil?
47
+ block ? block.call : progname
48
+ else
49
+ message
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module OpenTrace
6
+ class Logger < ::Logger
7
+ SEVERITY_MAP = {
8
+ ::Logger::DEBUG => "DEBUG",
9
+ ::Logger::INFO => "INFO",
10
+ ::Logger::WARN => "WARN",
11
+ ::Logger::ERROR => "ERROR",
12
+ ::Logger::FATAL => "FATAL",
13
+ ::Logger::UNKNOWN => "UNKNOWN"
14
+ }.freeze
15
+
16
+ attr_reader :wrapped_logger
17
+
18
+ def initialize(wrapped_logger, metadata: {})
19
+ @wrapped_logger = wrapped_logger
20
+ @default_metadata = metadata
21
+ # Initialize with nil logdev - we override #add to handle output
22
+ super(nil)
23
+ self.level = wrapped_logger.level if wrapped_logger.respond_to?(:level)
24
+ end
25
+
26
+ def add(severity, message = nil, progname = nil, &block)
27
+ # Delegate to wrapped logger first (synchronous, as required)
28
+ @wrapped_logger.add(severity, message, progname, &block)
29
+
30
+ # Forward to OpenTrace, never raise
31
+ forward_to_opentrace(severity, message, progname, &block)
32
+
33
+ true
34
+ rescue StandardError
35
+ # Never raise to the host app
36
+ true
37
+ end
38
+
39
+ # Support TaggedLogging if the wrapped logger uses it
40
+ def tagged(*tags, &block)
41
+ if @wrapped_logger.respond_to?(:tagged)
42
+ @wrapped_logger.tagged(*tags) do
43
+ @current_tags = current_tags_from_wrapped
44
+ block.call(self)
45
+ end
46
+ else
47
+ block.call(self)
48
+ end
49
+ rescue StandardError
50
+ block.call(self)
51
+ end
52
+
53
+ def flush
54
+ @wrapped_logger.flush if @wrapped_logger.respond_to?(:flush)
55
+ end
56
+
57
+ def close
58
+ @wrapped_logger.close if @wrapped_logger.respond_to?(:close)
59
+ end
60
+
61
+ # Proxy formatter to wrapped logger
62
+ def formatter
63
+ if @wrapped_logger.respond_to?(:formatter)
64
+ @wrapped_logger.formatter
65
+ else
66
+ super
67
+ end
68
+ end
69
+
70
+ def formatter=(f)
71
+ if @wrapped_logger&.respond_to?(:formatter=)
72
+ @wrapped_logger.formatter = f
73
+ else
74
+ super
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def forward_to_opentrace(severity, message, progname, &block)
81
+ return unless OpenTrace.enabled?
82
+
83
+ msg = resolve_message(message, progname, &block)
84
+ return if msg.nil? || (msg.is_a?(String) && msg.strip.empty?)
85
+
86
+ level_str = SEVERITY_MAP.fetch(severity || ::Logger::UNKNOWN, "UNKNOWN")
87
+
88
+ metadata = @default_metadata.dup
89
+ tags = current_tags_from_wrapped
90
+ metadata[:tags] = tags if tags && !tags.empty?
91
+
92
+ OpenTrace.log(level_str, msg.to_s, metadata)
93
+ rescue StandardError
94
+ # Swallow - never affect the host app
95
+ end
96
+
97
+ def resolve_message(message, progname, &block)
98
+ if message.nil?
99
+ if block
100
+ block.call
101
+ else
102
+ progname
103
+ end
104
+ else
105
+ message
106
+ end
107
+ end
108
+
109
+ def current_tags_from_wrapped
110
+ if @wrapped_logger.respond_to?(:formatter) &&
111
+ @wrapped_logger.formatter.respond_to?(:current_tags)
112
+ @wrapped_logger.formatter.current_tags.dup
113
+ end
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ request_id = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
11
+ OpenTrace.current_request_id = request_id
12
+
13
+ @app.call(env)
14
+ ensure
15
+ OpenTrace.current_request_id = nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(::Rails::Railtie)
4
+ module OpenTrace
5
+ class Railtie < ::Rails::Railtie
6
+ # Use config.after_initialize so that config/initializers/ files
7
+ # (where the user calls OpenTrace.configure) have already run.
8
+ # Register middleware early — before the stack is frozen
9
+ initializer "opentrace.middleware" do |app|
10
+ app.middleware.use OpenTrace::Middleware
11
+ end
12
+
13
+ config.after_initialize do |app|
14
+ next unless OpenTrace.enabled?
15
+
16
+ if Rails.logger.respond_to?(:broadcast_to)
17
+ # Rails 7.1+: register as a broadcast target (non-invasive)
18
+ Rails.logger.broadcast_to(OpenTrace::LogForwarder.new)
19
+ else
20
+ # Pre-7.1 fallback: wrap the logger directly
21
+ if app.config.logger
22
+ app.config.logger = OpenTrace::Logger.new(app.config.logger)
23
+ Rails.logger = app.config.logger
24
+ elsif Rails.logger
25
+ Rails.logger = OpenTrace::Logger.new(Rails.logger)
26
+ end
27
+ end
28
+
29
+ # Subscribe to controller request notifications
30
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
31
+ event = ActiveSupport::Notifications::Event.new(*args)
32
+ forward_request_log(event)
33
+ rescue StandardError
34
+ # Swallow - never affect the host app
35
+ end
36
+
37
+ # Subscribe to SQL query notifications
38
+ if OpenTrace.config.sql_logging
39
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
40
+ event = ActiveSupport::Notifications::Event.new(*args)
41
+ forward_sql_log(event)
42
+ rescue StandardError
43
+ # Swallow
44
+ end
45
+ end
46
+
47
+ # Subscribe to ActiveJob notifications
48
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
49
+ event = ActiveSupport::Notifications::Event.new(*args)
50
+ forward_job_log(event)
51
+ rescue StandardError
52
+ # Swallow
53
+ end
54
+ end
55
+
56
+ class << self
57
+ private
58
+
59
+ def forward_request_log(event)
60
+ return unless OpenTrace.enabled?
61
+
62
+ payload = event.payload
63
+ metadata = {
64
+ request_id: payload[:headers]&.env&.dig("action_dispatch.request_id"),
65
+ controller: payload[:controller],
66
+ action: payload[:action],
67
+ method: payload[:method],
68
+ path: payload[:path],
69
+ status: payload[:status],
70
+ duration_ms: event.duration&.round(1)
71
+ }.compact
72
+
73
+ # Attempt to capture current user ID if available
74
+ user_id = extract_user_id(payload)
75
+ metadata[:user_id] = user_id if user_id
76
+
77
+ # Exception auto-capture
78
+ if payload[:exception]
79
+ metadata[:exception_class] = payload[:exception][0]
80
+ metadata[:exception_message] = truncate(payload[:exception][1], 500)
81
+ end
82
+
83
+ if payload[:exception_object]&.backtrace
84
+ cleaned = clean_backtrace(payload[:exception_object].backtrace)
85
+ metadata[:backtrace] = cleaned.first(15)
86
+ end
87
+
88
+ # Filtered request params
89
+ extract_params(payload, metadata)
90
+
91
+ level = if payload[:exception]
92
+ "ERROR"
93
+ elsif payload[:status].to_i >= 500
94
+ "ERROR"
95
+ elsif payload[:status].to_i >= 400
96
+ "WARN"
97
+ else
98
+ "INFO"
99
+ end
100
+ message = "#{payload[:method]} #{payload[:path]} #{payload[:status]} #{event.duration&.round(1)}ms"
101
+
102
+ OpenTrace.log(level, message, metadata)
103
+ rescue StandardError
104
+ # Swallow
105
+ end
106
+
107
+ def forward_job_log(event)
108
+ return unless OpenTrace.enabled?
109
+
110
+ payload = event.payload
111
+ job = payload[:job]
112
+
113
+ metadata = {
114
+ job_class: job.class.name,
115
+ job_id: job.respond_to?(:job_id) ? job.job_id : nil,
116
+ queue_name: job.respond_to?(:queue_name) ? job.queue_name : nil,
117
+ executions: job.respond_to?(:executions) ? job.executions : nil,
118
+ duration_ms: event.duration&.round(1)
119
+ }.compact
120
+
121
+ # Capture arguments (truncated)
122
+ if job.respond_to?(:arguments)
123
+ args_json = JSON.generate(job.arguments)
124
+ metadata[:job_arguments] = if args_json.bytesize > 512
125
+ args_json[0, 512] + "..."
126
+ else
127
+ job.arguments
128
+ end
129
+ end
130
+
131
+ # Capture exceptions from job failures
132
+ if payload[:exception_object]
133
+ metadata[:exception_class] = payload[:exception_object].class.name
134
+ metadata[:exception_message] = truncate(payload[:exception_object].message, 500)
135
+ if payload[:exception_object].backtrace
136
+ metadata[:backtrace] = clean_backtrace(payload[:exception_object].backtrace).first(15)
137
+ end
138
+ end
139
+
140
+ level = payload[:exception_object] ? "ERROR" : "INFO"
141
+ message = if payload[:exception_object]
142
+ "Job #{job.class.name} FAILED (attempt #{job.respond_to?(:executions) ? job.executions : '?'})"
143
+ else
144
+ "Job #{job.class.name} completed #{event.duration&.round(1)}ms"
145
+ end
146
+
147
+ OpenTrace.log(level, message, metadata)
148
+ rescue StandardError
149
+ # Swallow
150
+ end
151
+
152
+ def forward_sql_log(event)
153
+ return unless OpenTrace.enabled?
154
+
155
+ payload = event.payload
156
+ duration = event.duration&.round(2)
157
+ threshold = OpenTrace.config.sql_duration_threshold_ms
158
+
159
+ # Skip if below threshold
160
+ return if threshold > 0 && duration && duration < threshold
161
+
162
+ # Skip SCHEMA queries (migrations, structure dumps)
163
+ return if payload[:name] == "SCHEMA"
164
+
165
+ metadata = {
166
+ sql_name: payload[:name],
167
+ sql: truncate(payload[:sql], 1000),
168
+ sql_duration_ms: duration,
169
+ sql_cached: payload[:cached] || false
170
+ }.compact
171
+
172
+ # Extract table name from SQL for easier filtering
173
+ if payload[:sql] =~ /\b(?:FROM|INTO|UPDATE|JOIN)\s+[`"]?(\w+)[`"]?/i
174
+ metadata[:sql_table] = $1
175
+ end
176
+
177
+ level = (duration && duration > 1000) ? "WARN" : "DEBUG"
178
+ message = "SQL #{payload[:name]} #{duration}ms"
179
+
180
+ OpenTrace.log(level, message, metadata)
181
+ rescue StandardError
182
+ # Swallow
183
+ end
184
+
185
+ def extract_user_id(payload)
186
+ controller = payload[:controller_instance]
187
+ return unless controller
188
+
189
+ if controller.respond_to?(:current_user, true)
190
+ user = controller.send(:current_user)
191
+ user.respond_to?(:id) ? user.id : nil
192
+ end
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ def extract_params(payload, metadata)
198
+ controller = payload[:controller_instance]
199
+ return unless controller
200
+
201
+ if controller.respond_to?(:request, true) && controller.request.respond_to?(:filtered_parameters)
202
+ params = controller.request.filtered_parameters
203
+ params = params.except("controller", "action")
204
+ metadata[:params] = truncate_hash(params, 2048) unless params.empty?
205
+ end
206
+ rescue StandardError
207
+ # Swallow
208
+ end
209
+
210
+ def truncate(str, max)
211
+ return str if str.nil? || str.length <= max
212
+ str[0, max] + "..."
213
+ end
214
+
215
+ def clean_backtrace(backtrace)
216
+ if defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner)
217
+ ::Rails.backtrace_cleaner.clean(backtrace)
218
+ else
219
+ backtrace.reject { |line| line.include?("/gems/") }
220
+ end
221
+ end
222
+
223
+ def truncate_hash(hash, max_bytes)
224
+ json = JSON.generate(hash)
225
+ return hash if json.bytesize <= max_bytes
226
+ { _truncated: true, _size: json.bytesize }
227
+ rescue StandardError
228
+ { _truncated: true }
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ VERSION = "0.1.0"
5
+ end
data/lib/opentrace.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "opentrace/version"
5
+ require_relative "opentrace/config"
6
+ require_relative "opentrace/client"
7
+ require_relative "opentrace/logger"
8
+ require_relative "opentrace/log_forwarder"
9
+ require_relative "opentrace/middleware"
10
+
11
+ module OpenTrace
12
+ LEVEL_VALUES = { "DEBUG" => 0, "INFO" => 1, "WARN" => 2, "ERROR" => 3, "FATAL" => 4 }.freeze
13
+
14
+ class << self
15
+ def configure
16
+ yield config
17
+ reset_client!
18
+ end
19
+
20
+ def config
21
+ @config ||= Config.new
22
+ end
23
+
24
+ def log(level, message, metadata = {})
25
+ return unless enabled?
26
+ return unless level_meets_threshold?(level)
27
+
28
+ # 1. Start with user-defined context (lowest priority)
29
+ meta = resolve_context
30
+
31
+ # 2. Merge caller-provided metadata (overrides context)
32
+ meta.merge!(metadata) if metadata.is_a?(Hash)
33
+
34
+ # 3. Static context — only fills in keys not already set
35
+ static_context.each { |k, v| meta[k] ||= v }
36
+
37
+ # 4. Request ID from middleware (if not already set by caller or context)
38
+ meta[:request_id] ||= current_request_id if current_request_id
39
+
40
+ # Extract trace_id to top level before building payload
41
+ trace_id = meta.delete(:trace_id)
42
+
43
+ payload = {
44
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
45
+ level: level.to_s.upcase,
46
+ service: config.service,
47
+ environment: config.environment,
48
+ message: message.to_s,
49
+ metadata: meta.compact
50
+ }
51
+
52
+ payload[:trace_id] = trace_id.to_s if trace_id
53
+
54
+ client.enqueue(payload)
55
+ rescue StandardError
56
+ # Never raise to the host app
57
+ end
58
+
59
+ def error(exception, metadata = {})
60
+ return unless enabled?
61
+
62
+ meta = metadata.is_a?(Hash) ? metadata.dup : {}
63
+ meta[:exception_class] = exception.class.name
64
+ meta[:exception_message] = exception.message&.slice(0, 500)
65
+
66
+ if exception.backtrace
67
+ cleaned = if defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner)
68
+ ::Rails.backtrace_cleaner.clean(exception.backtrace)
69
+ else
70
+ exception.backtrace.reject { |l| l.include?("/gems/") }
71
+ end
72
+ meta[:backtrace] = cleaned.first(15)
73
+ end
74
+
75
+ log("ERROR", exception.message.to_s, meta)
76
+ rescue StandardError
77
+ # Never raise to the host app
78
+ end
79
+
80
+ def enabled?
81
+ config.enabled?
82
+ end
83
+
84
+ def disable!
85
+ config.enabled = false
86
+ end
87
+
88
+ def enable!
89
+ config.enabled = true
90
+ end
91
+
92
+ def current_request_id
93
+ Thread.current[:opentrace_request_id]
94
+ end
95
+
96
+ def current_request_id=(id)
97
+ Thread.current[:opentrace_request_id] = id
98
+ end
99
+
100
+ def shutdown(timeout: 5)
101
+ @client&.shutdown(timeout: timeout)
102
+ end
103
+
104
+ def reset!
105
+ shutdown(timeout: 1)
106
+ @config = nil
107
+ @client = nil
108
+ @static_context = nil
109
+ end
110
+
111
+ private
112
+
113
+ def client
114
+ @client ||= Client.new(config)
115
+ end
116
+
117
+ def reset_client!
118
+ @client&.shutdown(timeout: 1)
119
+ @client = nil
120
+ @static_context = nil
121
+ end
122
+
123
+ def level_meets_threshold?(level)
124
+ LEVEL_VALUES[level.to_s.upcase].to_i >= config.min_level_value
125
+ end
126
+
127
+ def static_context
128
+ @static_context ||= {
129
+ hostname: config.hostname || Socket.gethostname,
130
+ pid: config.pid || Process.pid,
131
+ git_sha: config.git_sha || ENV["REVISION"] || ENV["GIT_SHA"] || ENV["HEROKU_SLUG_COMMIT"]
132
+ }.compact
133
+ rescue StandardError
134
+ {}
135
+ end
136
+
137
+ def resolve_context
138
+ ctx = case config.context
139
+ when Proc then config.context.call
140
+ when Hash then config.context
141
+ end
142
+ ctx.is_a?(Hash) ? ctx.dup : {}
143
+ rescue StandardError
144
+ {} # Broken proc? Swallow, never crash.
145
+ end
146
+ end
147
+ end
148
+
149
+ # Auto-load Rails integration if Rails is present
150
+ require_relative "opentrace/rails" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: opentrace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - OpenTrace
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Forwards structured application logs to an OpenTrace server over HTTP.
27
+ Designed to never affect application behavior or uptime.
28
+ email:
29
+ - hello@opentrace.dev
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - lib/opentrace.rb
37
+ - lib/opentrace/client.rb
38
+ - lib/opentrace/config.rb
39
+ - lib/opentrace/log_forwarder.rb
40
+ - lib/opentrace/logger.rb
41
+ - lib/opentrace/middleware.rb
42
+ - lib/opentrace/rails.rb
43
+ - lib/opentrace/version.rb
44
+ homepage: https://github.com/adham90/opentrace-ruby
45
+ licenses:
46
+ - MIT
47
+ metadata: {}
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.0.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.3
63
+ specification_version: 4
64
+ summary: Thin, safe Ruby client for OpenTrace log ingestion
65
+ test_files: []