Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
42 changes: 41 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,47 @@
config.sidekiq.propagate_traces = false unless Rails.const_defined?('Server')
```
- Only expose `active_storage` keys on span data if `send_default_pii` is on ([#2589](https://github.com/getsentry/sentry-ruby/pull/2589))
- Add `Sentry.capture_log` ([#2606](https://github.com/getsentry/sentry-ruby/pull/2617))
- Add new `Sentry.logger` for [Structured Logging](https://develop.sentry.dev/sdk/telemetry/logs/) feature ([#2620](https://github.com/getsentry/sentry-ruby/pull/2620)).

To enable structured logging you need to turn on the `enable_logs` configuration option:
```ruby
Sentry.init do |config|
# ... your setup ...
config.enable_logs = true
end
```

Once you configured structured logging, you get access to a new `Sentry.logger` object that can be
used as a regular logger with additional structured data support:

```ruby
Sentry.logger.info("User logged in", user_id: 123)

Sentry.logger.error("Failed to process payment",
transaction_id: "tx_123",
error_code: "PAYMENT_FAILED"
)
```

You can also use message templates with positional or hash parameters:

```ruby
Sentry.logger.info("User %{name} logged in", name: "Jane Doe")

Sentry.logger.info("User %s logged in", ["Jane Doe"])
```

Any other arbitrary attributes will be sent as part of the log event payload:

```ruby
# Here `user_id` and `action` will be sent as extra attributes that Sentry Logs UI displays
Sentry.logger.info("User %{user} logged in", user: "Jane", user_id: 123, action: "create")
```

:warning: When `enable_logs` is `true`, previous `Sentry.logger` should no longer be used for internal SDK
logging - it was replaced by `Sentry.configuration.sdk_logger` and should be used only by the SDK
itself and its extensions.

- New configuration option called `active_job_report_on_retry_error` which enables reporting errors on each retry error ([#2617](https://github.com/getsentry/sentry-ruby/pull/2617))

### Bug Fixes
Expand Down
72 changes: 52 additions & 20 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
require "sentry/utils/encoding_helper"
require "sentry/utils/logging_helper"
require "sentry/configuration"
require "sentry/logger"
require "sentry/structured_logger"
require "sentry/event"
require "sentry/error_event"
require "sentry/transaction_event"
Expand Down Expand Up @@ -54,6 +54,7 @@ module Sentry

GLOBALS = %i[
main_hub
logger
session_flusher
backpressure_monitor
metrics_aggregator
Expand Down Expand Up @@ -94,11 +95,6 @@ def exception_locals_tp
# @return [Metrics::Aggregator, nil]
attr_reader :metrics_aggregator

# @!attribute [r] logger
# @return [Logger]
# @!visibility private
attr_reader :sdk_logger

##### Patch Registration #####

# @!visibility private
Expand Down Expand Up @@ -244,9 +240,6 @@ def init(&block)
config = Configuration.new
yield(config) if block_given?

# Internal SDK logger
@sdk_logger = config.sdk_logger

config.detect_release
apply_patches(config)
config.validate
Expand Down Expand Up @@ -499,12 +492,19 @@ def capture_check_in(slug, status, **options)
end

# Captures a log event and sends it to Sentry via the currently active hub.
# This is the underlying method used by the StructuredLogger class.
#
# @param message [String] the log message
# @param [Hash] options Extra log event options
# @option options [Symbol] level The log level
# @option options [Symbol] level The log level (:trace, :debug, :info, :warn, :error, :fatal)
# @option options [Integer] severity The severity number according to the Sentry Logs Protocol
# @option options [Hash] Additional attributes to include with the log
#
# @return [LogEvent, nil]
# @example Direct usage (prefer using Sentry.logger instead)
# Sentry.capture_log("User logged in", level: :info, user_id: 123)
#
# @see https://develop.sentry.dev/sdk/telemetry/logs/ Sentry SDK Telemetry Logs Protocol
# @return [LogEvent, nil] The created log event or nil if logging is disabled
def capture_log(message, **options)
return unless initialized?
get_current_hub.capture_log_event(message, **options)
Expand Down Expand Up @@ -614,18 +614,45 @@ def continue_trace(env, **options)
get_current_hub.continue_trace(env, **options)
end

##### Helpers #####

# @!visibility private
# Returns the structured logger instance that implements Sentry's SDK telemetry logs protocol.
#
# This logger is only available when logs are enabled in the configuration.
#
# @example Enable logs in configuration
# Sentry.init do |config|
# config.dsn = "YOUR_DSN"
# config.enable_logs = true
# end
#
# @example Basic usage
# Sentry.logger.info("User logged in successfully", user_id: 123)
# Sentry.logger.error("Failed to process payment",
# transaction_id: "tx_123",
# error_code: "PAYMENT_FAILED"
# )
#
# @see https://develop.sentry.dev/sdk/telemetry/logs/ Sentry SDK Telemetry Logs Protocol
#
# @return [StructuredLogger, nil] The structured logger instance or nil if logs are disabled
def logger
warn <<~STR
[sentry] `Sentry.logger` will no longer be used as internal SDK logger when `enable_logs` feature is turned on.
Use Sentry.configuration.sdk_logger for SDK-specific logging needs."
STR

configuration.sdk_logger
@logger ||=
if configuration.enable_logs
# Initialize the public-facing Structured Logger if logs are enabled
# This creates a StructuredLogger instance that implements Sentry's SDK telemetry logs protocol
# @see https://develop.sentry.dev/sdk/telemetry/logs/
StructuredLogger.new(configuration)
else
warn <<~STR
[sentry] `Sentry.logger` will no longer be used as internal SDK logger when `enable_logs` feature is turned on.
Use Sentry.configuration.sdk_logger for SDK-specific logging needs."
STR

configuration.sdk_logger
end
end

##### Helpers #####

# @!visibility private
def sys_command(command)
result = `#{command} 2>&1` rescue nil
Expand All @@ -634,6 +661,11 @@ def sys_command(command)
result.strip
end

# @!visibility private
def sdk_logger
configuration.sdk_logger
end

# @!visibility private
def sdk_meta
META
Expand Down
12 changes: 11 additions & 1 deletion sentry-ruby/lib/sentry/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class Client
# @return [SpotlightTransport, nil]
attr_reader :spotlight_transport

# @!visibility private
attr_reader :log_event_buffer

# @!macro configuration
attr_reader :configuration

Expand Down Expand Up @@ -179,10 +182,17 @@ def event_from_check_in(
end

# Initializes a LogEvent object with the given message and options
#
# @param message [String] the log message
# @param level [Symbol] the log level (:trace, :debug, :info, :warn, :error, :fatal)
# @param options [Hash] additional options
# @option options [Array] :parameters Array of values to replace template tokens in the message
#
# @return [LogEvent] the created log event
def event_from_log(message, level:, **options)
return unless configuration.sending_allowed?

attributes = options.reject { |k, _| k == :level }
attributes = options.reject { |k, _| k == :level || k == :severity }

LogEvent.new(
level: level,
Expand Down
6 changes: 6 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require "sentry/metrics/configuration"
require "sentry/linecache"
require "sentry/interfaces/stacktrace_builder"
require "sentry/logger"
require "sentry/log_event_buffer"

module Sentry
Expand Down Expand Up @@ -275,6 +276,10 @@ def logger
# @return [Proc]
attr_accessor :traces_sampler

# Enable Structured Logging
# @return [Boolean]
attr_accessor :enable_logs

# Easier way to use performance tracing
# If set to true, will set traces_sample_rate to 1.0
# @deprecated It will be removed in the next major release.
Expand Down Expand Up @@ -463,6 +468,7 @@ def initialize
self.rack_env_whitelist = RACK_ENV_WHITELIST_DEFAULT
self.traces_sampler = nil
self.enable_tracing = nil
self.enable_logs = false

self.profiler_class = Sentry::Profiler

Expand Down
60 changes: 53 additions & 7 deletions sentry-ruby/lib/sentry/log_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ module Sentry
class LogEvent < Event
TYPE = "log"

DEFAULT_PARAMETERS = [].freeze
DEFAULT_ATTRIBUTES = {}.freeze
DEFAULT_CONTEXT = {}.freeze

SERIALIZEABLE_ATTRIBUTES = %i[
level
body
Expand All @@ -21,20 +25,23 @@ class LogEvent < Event
"sentry.release" => :release,
"sentry.address" => :server_name,
"sentry.sdk.name" => :sdk_name,
"sentry.sdk.version" => :sdk_version
"sentry.sdk.version" => :sdk_version,
"sentry.message.template" => :template
}

LEVELS = %i[trace debug info warn error fatal].freeze

attr_accessor :level, :body, :attributes, :trace_id
attr_accessor :level, :body, :template, :attributes

def initialize(configuration: Sentry.configuration, **options)
super(configuration: configuration)

@type = TYPE
@level = options.fetch(:level)
@body = options[:body]
@attributes = options[:attributes] || {}
@contexts = {}
@template = @body if is_template?
@attributes = options[:attributes] || DEFAULT_ATTRIBUTES
@contexts = DEFAULT_CONTEXT
end

def to_hash
Expand Down Expand Up @@ -72,15 +79,25 @@ def serialize_timestamp
end

def serialize_trace_id
@contexts.dig(:trace, :trace_id)
contexts.dig(:trace, :trace_id)
end

def serialize_parent_span_id
@contexts.dig(:trace, :parent_span_id)
contexts.dig(:trace, :parent_span_id)
end

def serialize_body
if parameters.empty?
body
elsif parameters.is_a?(Hash)
body % parameters
else
sprintf(body, *parameters)
end
end

def serialize_attributes
hash = @attributes.each_with_object({}) do |(key, value), memo|
hash = attributes.each_with_object({}) do |(key, value), memo|
memo[key] = attribute_hash(value)
end

Expand Down Expand Up @@ -109,5 +126,34 @@ def value_type(value)
"string"
end
end

def parameters
@parameters ||= begin
return DEFAULT_PARAMETERS unless template

parameters = template_tokens.empty? ?
attributes.fetch(:parameters, DEFAULT_PARAMETERS) : attributes.slice(*template_tokens)

if parameters.is_a?(Hash)
parameters.each do |key, value|
attributes["sentry.message.parameters.#{key}"] = value
end
else
parameters.each_with_index do |param, index|
attributes["sentry.message.parameters.#{index}"] = param
Copy link
Member

@sl0thentr0py sl0thentr0py May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they just changed this to sentry.message.parameter
see getsentry/sentry-python#4387

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something something hardest problem in computer science

end
end
end
end

TOKEN_REGEXP = /%\{(\w+)\}/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should have a clear note in docs that our templates are expected to be like

"This is a template with a %{var}"

and not what ruby people are used to

"This is a template with a #{var}"


def template_tokens
@template_tokens ||= body.scan(TOKEN_REGEXP).flatten.map(&:to_sym)
end

def is_template?
body.include?("%s") || TOKEN_REGEXP.match?(body)
end
end
end
20 changes: 16 additions & 4 deletions sentry-ruby/lib/sentry/log_event_buffer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
require "sentry/threaded_periodic_worker"

module Sentry
# LogEventBuffer buffers log events and sends them to Sentry in a single envelope.
#
# This is used internally by the `Sentry::Client`.
#
# @!visibility private
class LogEventBuffer < ThreadedPeriodicWorker
FLUSH_INTERVAL = 5 # seconds
DEFAULT_MAX_EVENTS = 100

# @!visibility private
attr_reader :pending_events

def initialize(configuration, client)
super(configuration.sdk_logger, FLUSH_INTERVAL)

Expand All @@ -27,13 +35,11 @@ def start

def flush
@mutex.synchronize do
return unless size >= @max_events
return if empty?

log_debug("[LogEventBuffer] flushing #{size} log events")

@client.send_envelope(to_envelope)

@pending_events.clear
send_events
end

log_debug("[LogEventBuffer] flushed #{size} log events")
Expand All @@ -47,6 +53,7 @@ def add_event(event)

@mutex.synchronize do
@pending_events << event
send_events if size >= @max_events
end

self
Expand All @@ -62,6 +69,11 @@ def size

private

def send_events
@client.send_envelope(to_envelope)
@pending_events.clear
end

def to_envelope
envelope = Envelope.new(
event_id: SecureRandom.uuid.delete("-"),
Expand Down
Loading
Loading