Skip to content

Introduce Sentry.capture_log #2606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
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.server_name" => :server_name,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"sentry.server_name" => :server_name,
"server.address" => :server_name,

This follows the OpenTelemetry semantic conventions for server, which we want to follow: https://opentelemetry.io/docs/specs/semconv/attributes-registry/server/#server-attributes

"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.server_name"]).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