Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ All adapters are pure Ruby implementations with no external SDK dependencies:
- **Insight Hub** (formerly Bugsnag) - Error tracking with breadcrumbs
- **Telebugs** - Sentry-compatible protocol (perfect for self-hosted alternatives)
- **OpenObserve** - Open-source observability platform (logs, metrics, traces)
- **OTLP** - OpenTelemetry Protocol (works with SigNoz, Jaeger, Tempo, Honeycomb, etc.)

## Configuration

Expand Down Expand Up @@ -270,6 +271,24 @@ Lapsoss.configure do |config|
end
```

### Using OTLP (OpenTelemetry Protocol)

```ruby
# Works with SigNoz, Jaeger, Tempo, Honeycomb, Datadog, etc.
Lapsoss.configure do |config|
config.use_otlp(
endpoint: ENV['OTLP_ENDPOINT'], # e.g., "http://localhost:4318"
service_name: "my-rails-app",
headers: { "X-Custom-Header" => "value" } # optional custom headers
)

# Or use convenience helpers for specific services
config.use_signoz(signoz_api_key: ENV['SIGNOZ_API_KEY'])
config.use_jaeger(endpoint: "http://jaeger:4318")
config.use_tempo(endpoint: "http://tempo:4318")
end
```

### Advanced Configuration

```ruby
Expand Down
70 changes: 70 additions & 0 deletions lib/lapsoss/adapters/concerns/stacktrace_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require "active_support/concern"
require "active_support/core_ext/object/blank"

module Lapsoss
module Adapters
module Concerns
# Shared stacktrace building logic for adapters
# Provides consistent frame formatting across Sentry, OpenObserve, OTLP, etc.
module StacktraceBuilder
extend ActiveSupport::Concern

# Build frames from event backtrace
# @param event [Lapsoss::Event] The event with backtrace_frames
# @param reverse [Boolean] Reverse frame order (Sentry expects oldest-to-newest)
# @return [Array<Hash>] Array of formatted frame hashes
def build_frames(event, reverse: false)
return [] unless event.has_backtrace?

frames = event.backtrace_frames.map { |frame| build_frame(frame) }
reverse ? frames.reverse : frames
end

# Build a single frame hash from a BacktraceFrame
# @param frame [Lapsoss::BacktraceFrame] The frame to format
# @return [Hash] Formatted frame hash
def build_frame(frame)
frame_hash = {
filename: frame.filename,
abs_path: frame.absolute_path || frame.filename,
function: frame.method_name || frame.function,
lineno: frame.line_number,
in_app: frame.in_app
}

add_code_context(frame_hash, frame) if frame.code_context.present?

frame_hash.compact
end

# Build raw stacktrace string for logging/simple formats
# @param event [Lapsoss::Event] The event with backtrace_frames
# @return [Array<String>] Array of formatted frame strings
def build_raw_stacktrace(event)
return [] unless event.has_backtrace?

event.backtrace_frames.map do |frame|
"#{frame.absolute_path || frame.filename}:#{frame.line_number} in `#{frame.method_name}`"
end
end

# Build stacktrace as single string (for OTLP exception.stacktrace)
# @param event [Lapsoss::Event] The event with backtrace_frames
# @return [String] Newline-separated stacktrace
def build_stacktrace_string(event)
build_raw_stacktrace(event).join("\n")
end

private

def add_code_context(frame_hash, frame)
frame_hash[:pre_context] = frame.code_context[:pre_context]
frame_hash[:context_line] = frame.code_context[:context_line]
frame_hash[:post_context] = frame.code_context[:post_context]
end
end
end
end
end
52 changes: 52 additions & 0 deletions lib/lapsoss/adapters/concerns/trace_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require "active_support/concern"
require "securerandom"

module Lapsoss
module Adapters
module Concerns
# Trace context utilities for OTLP and distributed tracing
# Provides trace/span ID generation and timestamp formatting
module TraceContext
extend ActiveSupport::Concern

# Generate a W3C Trace Context compliant trace ID (32 hex chars = 128 bits)
# @return [String] 32 character hex string
def generate_trace_id
SecureRandom.hex(16)
end

# Generate a W3C Trace Context compliant span ID (16 hex chars = 64 bits)
# @return [String] 16 character hex string
def generate_span_id
SecureRandom.hex(8)
end

# Convert time to nanoseconds since Unix epoch (OTLP format)
# @param time [Time] The time to convert
# @return [Integer] Nanoseconds since Unix epoch
def timestamp_nanos(time)
time ||= Time.current
(time.to_f * 1_000_000_000).to_i
end

# Convert time to microseconds since Unix epoch (OpenObserve format)
# @param time [Time] The time to convert
# @return [Integer] Microseconds since Unix epoch
def timestamp_micros(time)
time ||= Time.current
(time.to_f * 1_000_000).to_i
end

# Convert time to milliseconds since Unix epoch
# @param time [Time] The time to convert
# @return [Integer] Milliseconds since Unix epoch
def timestamp_millis(time)
time ||= Time.current
(time.to_f * 1_000).to_i
end
end
end
end
end
231 changes: 231 additions & 0 deletions lib/lapsoss/adapters/otlp_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# frozen_string_literal: true

require "active_support/core_ext/object/blank"

module Lapsoss
module Adapters
# OTLP adapter - sends errors via OpenTelemetry Protocol
# Works with any OTLP-compatible backend: SigNoz, Jaeger, Tempo, Honeycomb, etc.
# Docs: https://opentelemetry.io/docs/specs/otlp/
class OtlpAdapter < Base
include Concerns::HttpDelivery
include Concerns::StacktraceBuilder
include Concerns::TraceContext
include Concerns::EnvelopeBuilder

# OTLP status codes
STATUS_CODE_UNSET = 0
STATUS_CODE_OK = 1
STATUS_CODE_ERROR = 2

# OTLP span kinds
SPAN_KIND_INTERNAL = 1

DEFAULT_ENDPOINT = "http://localhost:4318"

def initialize(name, settings = {})
super

@endpoint = settings[:endpoint].presence || ENV["OTLP_ENDPOINT"] || DEFAULT_ENDPOINT
@headers = settings[:headers] || {}
@service_name = settings[:service_name].presence || ENV["OTEL_SERVICE_NAME"] || "rails"
@environment = settings[:environment].presence || ENV["OTEL_ENVIRONMENT"] || "production"

# Support common auth patterns
if (api_key = settings[:api_key].presence || ENV["OTLP_API_KEY"])
@headers["Authorization"] = "Bearer #{api_key}"
end

if (signoz_key = settings[:signoz_api_key].presence || ENV["SIGNOZ_API_KEY"])
@headers["signoz-access-token"] = signoz_key
end

setup_endpoint
end

def capture(event)
deliver(event.scrubbed)
end

def capabilities
super.merge(
breadcrumbs: false,
code_context: true,
data_scrubbing: true
)
end

private

def setup_endpoint
uri = URI.parse(@endpoint)
@api_endpoint = "#{uri.scheme}://#{uri.host}:#{uri.port}"
@api_path = "/v1/traces"
end

def build_payload(event)
{
resourceSpans: [ build_resource_spans(event) ]
}
end

def build_resource_spans(event)
{
resource: build_resource(event),
scopeSpans: [ build_scope_spans(event) ]
}
end

def build_resource(event)
attributes = [
{ key: "service.name", value: { stringValue: @service_name } },
{ key: "deployment.environment", value: { stringValue: event.environment.presence || @environment } },
{ key: "telemetry.sdk.name", value: { stringValue: "lapsoss" } },
{ key: "telemetry.sdk.version", value: { stringValue: Lapsoss::VERSION } },
{ key: "telemetry.sdk.language", value: { stringValue: "ruby" } }
]

# Add user context as resource attributes if available
if event.user_context.present?
event.user_context.each do |key, value|
attributes << { key: "user.#{key}", value: attribute_value(value) }
end
end

{ attributes: attributes }
end

def build_scope_spans(event)
{
scope: {
name: "lapsoss",
version: Lapsoss::VERSION
},
spans: [ build_span(event) ]
}
end

def build_span(event)
now = timestamp_nanos(event.timestamp)
span_name = event.type == :exception ? event.exception_type : "message"

span = {
traceId: generate_trace_id,
spanId: generate_span_id,
name: span_name,
kind: SPAN_KIND_INTERNAL,
startTimeUnixNano: now.to_s,
endTimeUnixNano: now.to_s,
status: build_status(event),
attributes: build_span_attributes(event)
}

# Add exception event for exception types
if event.type == :exception
span[:events] = [ build_exception_event(event) ]
end

span
end

def build_status(event)
if event.type == :exception || event.level == :error || event.level == :fatal
{ code: STATUS_CODE_ERROR, message: event.exception_message || event.message || "Error" }
else
{ code: STATUS_CODE_OK }
end
end

def build_span_attributes(event)
attributes = []

# Add tags
event.tags&.each do |key, value|
attributes << { key: key.to_s, value: attribute_value(value) }
end

# Add extra data
event.extra&.each do |key, value|
attributes << { key: "extra.#{key}", value: attribute_value(value) }
end

# Add request context
if event.request_context.present?
event.request_context.each do |key, value|
attributes << { key: "http.#{key}", value: attribute_value(value) }
end
end

# Add transaction name
if event.transaction.present?
attributes << { key: "transaction.name", value: { stringValue: event.transaction } }
end

# Add fingerprint
if event.fingerprint.present?
attributes << { key: "error.fingerprint", value: { stringValue: event.fingerprint } }
end

# Add message for message events
if event.type == :message && event.message.present?
attributes << { key: "message", value: { stringValue: event.message } }
end

attributes
end

def build_exception_event(event)
attributes = [
{ key: "exception.type", value: { stringValue: event.exception_type } },
{ key: "exception.message", value: { stringValue: event.exception_message } }
]

# Add stacktrace
if event.has_backtrace?
attributes << {
key: "exception.stacktrace",
value: { stringValue: build_stacktrace_string(event) }
}
end

{
name: "exception",
timeUnixNano: timestamp_nanos(event.timestamp).to_s,
attributes: attributes
}
end

# Convert Ruby value to OTLP attribute value
def attribute_value(value)
case value
when String
{ stringValue: value }
when Integer
{ intValue: value.to_s }
when Float
{ doubleValue: value }
when TrueClass, FalseClass
{ boolValue: value }
when Array
{ arrayValue: { values: value.map { |v| attribute_value(v) } } }
else
{ stringValue: value.to_s }
end
end

def serialize_payload(payload)
json = ActiveSupport::JSON.encode(payload)

if json.bytesize >= compress_threshold
[ ActiveSupport::Gzip.compress(json), true ]
else
[ json, false ]
end
end

def adapter_specific_headers
@headers.dup
end
end
end
end
Loading