Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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/2606))

### Bug Fixes

Expand Down
12 changes: 12 additions & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,18 @@ def capture_check_in(slug, status, **options)
get_current_hub.capture_check_in(slug, status, **options)
end

# Captures a log event and sends it to Sentry via the currently active hub.
#
# @param message [String] the log message
# @param [Hash] options Extra log event options
# @option options [Symbol] level The log level
#
# @return [LogEvent, nil]
def capture_log(message, **options)
return unless initialized?
get_current_hub.capture_log_event(message, **options)
end

# Takes or initializes a new Sentry::Transaction and makes a sampling decision for it.
#
# @return [Transaction, nil]
Expand Down
15 changes: 15 additions & 0 deletions sentry-ruby/lib/sentry/client.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "sentry/transport"
require "sentry/log_event"

module Sentry
class Client
Expand Down Expand Up @@ -167,6 +168,20 @@ def event_from_check_in(
)
end

# Initializes a LogEvent object with the given message and options
def event_from_log(message, level:, **options)
return unless configuration.sending_allowed?

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

LogEvent.new(
level: level,
body: message,
timestamp: Time.now.to_f,
attributes: attributes
)
end

# Initializes an Event object with the given Transaction object.
# @param transaction [Transaction] the transaction to be recorded.
# @return [TransactionEvent]
Expand Down
2 changes: 1 addition & 1 deletion sentry-ruby/lib/sentry/envelope/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Envelope::Item
# rate limits and client reports use the data_category rather than envelope item type
def self.data_category(type)
case type
when "session", "attachment", "transaction", "profile", "span" then type
when "session", "attachment", "transaction", "profile", "span", "log" then type
when "sessions" then "session"
when "check_in" then "monitor"
when "statsd", "metric_meta" then "metric_bucket"
Expand Down
10 changes: 10 additions & 0 deletions sentry-ruby/lib/sentry/hub.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ def capture_check_in(slug, status, **options)
event.check_in_id
end

def capture_log_event(message, **options)
return unless current_client

event = current_client.event_from_log(message, **options)

return unless event

capture_event(event, **options)
end

def capture_event(event, **options, &block)
check_argument_type!(event, Sentry::Event)

Expand Down
113 changes: 113 additions & 0 deletions sentry-ruby/lib/sentry/log_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

module Sentry
# Event type that represents a log entry with its attributes
#
# @see https://develop.sentry.dev/sdk/telemetry/logs/#log-envelope-item-payload
class LogEvent < Event
TYPE = "log"

SERIALIZEABLE_ATTRIBUTES = %i[
level
body
timestamp
trace_id
attributes
]

SENTRY_ATTRIBUTES = {
"sentry.trace.parent_span_id" => :parent_span_id,
"sentry.environment" => :environment,
"sentry.release" => :release,
"sentry.address" => :server_name,
"sentry.sdk.name" => :sdk_name,
"sentry.sdk.version" => :sdk_version
}

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

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

def initialize(configuration: Sentry.configuration, **options)
super(configuration: configuration)
@type = TYPE
@level = options.fetch(:level)
@body = options[:body]
@attributes = options[:attributes] || {}
@contexts = {}
end

def to_hash
SERIALIZEABLE_ATTRIBUTES.each_with_object({}) do |name, memo|
memo[name] = serialize(name)
end
end

private

def serialize(name)
serializer = :"serialize_#{name}"

if respond_to?(serializer, true)
__send__(serializer)
else
public_send(name)
end
end

def serialize_level
level.to_s
end

def serialize_sdk_name
Sentry.sdk_meta["name"]
end

def serialize_sdk_version
Sentry.sdk_meta["version"]
end

def serialize_timestamp
Time.parse(timestamp).to_f
end

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

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

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

SENTRY_ATTRIBUTES.each do |key, name|
if (value = serialize(name))
hash[key] = attribute_hash(value)
end
end

hash
end

def attribute_hash(value)
{ value: value, type: value_type(value) }
end

def value_type(value)
case value
when Integer
"integer"
when TrueClass, FalseClass
"boolean"
when Float
"double"
else
"string"
end
end
end
end
2 changes: 1 addition & 1 deletion sentry-ruby/lib/sentry/scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def apply_to_event(event, hint = nil)
event.transaction = transaction_name if transaction_name
event.transaction_info = { source: transaction_source } if transaction_source
event.fingerprint = fingerprint
event.level = level
event.level = level unless event.is_a?(LogEvent)
event.breadcrumbs = breadcrumbs
event.rack_env = rack_env if rack_env
event.attachments = attachments
Expand Down
19 changes: 15 additions & 4 deletions sentry-ruby/lib/sentry/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,21 @@ def envelope_from_event(event)

envelope = Envelope.new(envelope_headers)

envelope.add_item(
{ type: item_type, content_type: "application/json" },
event_payload
)
if event.is_a?(LogEvent)
envelope.add_item(
{
type: "log",
item_count: 1,
content_type: "application/vnd.sentry.items.log+json"
},
{ items: [event_payload] }
)
else
envelope.add_item(
{ type: item_type, content_type: "application/json" },
event_payload
)
end

if event.is_a?(TransactionEvent) && event.profile
envelope.add_item(
Expand Down
11 changes: 11 additions & 0 deletions sentry-ruby/spec/sentry/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def sentry_context
envelope.add_item({ type: 'event' }, { payload: 'test' })
envelope.add_item({ type: 'statsd' }, { payload: 'test2' })
envelope.add_item({ type: 'transaction' }, { payload: 'test3' })
envelope.add_item({ type: 'log' }, { level: 'info', message: 'test4' })
envelope
end

Expand All @@ -119,6 +120,15 @@ def sentry_context
subject.send_envelope(envelope)
end

it 'includes log item in the envelope' do
log_item = envelope.items.find { |item| item.type == 'log' }

expect(log_item).not_to be_nil
expect(log_item.payload[:level]).to eq('info')
expect(log_item.payload[:message]).to eq('test4')
expect(log_item.data_category).to eq('log')
end

it 'sends envelope with spotlight transport if enabled' do
configuration.spotlight = true

Expand Down Expand Up @@ -153,6 +163,7 @@ def sentry_context
expect(subject.transport).to have_recorded_lost_event(:network_error, 'error')
expect(subject.transport).to have_recorded_lost_event(:network_error, 'metric_bucket')
expect(subject.transport).to have_recorded_lost_event(:network_error, 'transaction')
expect(subject.transport).to have_recorded_lost_event(:network_error, 'log')
end
end
end
Expand Down
1 change: 1 addition & 0 deletions sentry-ruby/spec/sentry/envelope/item_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
['transaction', 'transaction'],
['span', 'span'],
['profile', 'profile'],
['log', 'log'],
['check_in', 'monitor'],
['statsd', 'metric_bucket'],
['metric_meta', 'metric_bucket'],
Expand Down
103 changes: 103 additions & 0 deletions sentry-ruby/spec/sentry/log_event_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Sentry::LogEvent do
let(:configuration) do
Sentry::Configuration.new.tap do |config|
config.dsn = Sentry::TestHelper::DUMMY_DSN
end
end

describe "#initialize" do
it "initializes with required attributes" do
event = described_class.new(
configuration: configuration,
level: :info,
body: "User John has logged in!"
)

expect(event).to be_a(described_class)
expect(event.level).to eq(:info)
expect(event.body).to eq("User John has logged in!")
end

it "accepts attributes" do
attributes = {
"sentry.message.template" => "User %s has logged in!",
"sentry.message.parameters.0" => "John"
}

event = described_class.new(
configuration: configuration,
level: :info,
body: "User John has logged in!",
attributes: attributes
)

expect(event.attributes).to eq(attributes)
end
end

describe "#to_hash" do
before do
configuration.release = "1.2.3"
configuration.environment = "test"
configuration.server_name = "server-123"
end

it "includes all required fields" do
attributes = {
"sentry.message.template" => "User %s has logged in!",
"sentry.message.parameters.0" => "John"
}

event = described_class.new(
configuration: configuration,
level: :info,
body: "User John has logged in!",
attributes: attributes
)

hash = event.to_hash

expect(hash[:level]).to eq("info")
expect(hash[:body]).to eq("User John has logged in!")
expect(hash[:timestamp]).to be_a(Float)

attributes = hash[:attributes]

expect(attributes).to be_a(Hash)
expect(attributes["sentry.message.template"]).to eq({ value: "User %s has logged in!", type: "string" })
expect(attributes["sentry.message.parameters.0"]).to eq({ value: "John", type: "string" })
expect(attributes["sentry.environment"]).to eq({ value: "test", type: "string" })
expect(attributes["sentry.release"]).to eq({ value: "1.2.3", type: "string" })
expect(attributes["sentry.address"]).to eq({ value: "server-123", type: "string" })
expect(attributes["sentry.sdk.name"]).to eq({ value: "sentry.ruby", type: "string" })
expect(attributes["sentry.sdk.version"]).to eq({ value: Sentry::VERSION, type: "string" })
end

it "serializes different attribute types correctly" do
attributes = {
"string_attr" => "string value",
"integer_attr" => 42,
"boolean_attr" => true,
"float_attr" => 3.14
}

event = described_class.new(
configuration: configuration,
level: :info,
body: "Test message",
attributes: attributes
)

hash = event.to_hash

expect(hash[:attributes]["string_attr"]).to eq({ value: "string value", type: "string" })
expect(hash[:attributes]["integer_attr"]).to eq({ value: 42, type: "integer" })
expect(hash[:attributes]["boolean_attr"]).to eq({ value: true, type: "boolean" })
expect(hash[:attributes]["float_attr"]).to eq({ value: 3.14, type: "double" })
end
end
end
Loading
Loading