diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0bcb716..eb3a96a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index fea912c0a..f2c81ae3a 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -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] diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index 5435302e0..30ebc963c 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "sentry/transport" +require "sentry/log_event" module Sentry class Client @@ -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] diff --git a/sentry-ruby/lib/sentry/envelope/item.rb b/sentry-ruby/lib/sentry/envelope/item.rb index e1539bf6c..864ad0239 100644 --- a/sentry-ruby/lib/sentry/envelope/item.rb +++ b/sentry-ruby/lib/sentry/envelope/item.rb @@ -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" diff --git a/sentry-ruby/lib/sentry/hub.rb b/sentry-ruby/lib/sentry/hub.rb index 5e233a665..bc02de65d 100644 --- a/sentry-ruby/lib/sentry/hub.rb +++ b/sentry-ruby/lib/sentry/hub.rb @@ -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) diff --git a/sentry-ruby/lib/sentry/log_event.rb b/sentry-ruby/lib/sentry/log_event.rb new file mode 100644 index 000000000..52160a1a6 --- /dev/null +++ b/sentry-ruby/lib/sentry/log_event.rb @@ -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 diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index 4feb6ecdd..68dfc079c 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -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 diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index d446387f2..40c313a12 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -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( diff --git a/sentry-ruby/spec/sentry/client_spec.rb b/sentry-ruby/spec/sentry/client_spec.rb index e069ccc5b..51e493023 100644 --- a/sentry-ruby/spec/sentry/client_spec.rb +++ b/sentry-ruby/spec/sentry/client_spec.rb @@ -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 @@ -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 @@ -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 diff --git a/sentry-ruby/spec/sentry/envelope/item_spec.rb b/sentry-ruby/spec/sentry/envelope/item_spec.rb index ea9cf1019..f21fb11b7 100644 --- a/sentry-ruby/spec/sentry/envelope/item_spec.rb +++ b/sentry-ruby/spec/sentry/envelope/item_spec.rb @@ -11,6 +11,7 @@ ['transaction', 'transaction'], ['span', 'span'], ['profile', 'profile'], + ['log', 'log'], ['check_in', 'monitor'], ['statsd', 'metric_bucket'], ['metric_meta', 'metric_bucket'], diff --git a/sentry-ruby/spec/sentry/log_event_spec.rb b/sentry-ruby/spec/sentry/log_event_spec.rb new file mode 100644 index 000000000..fdc436abb --- /dev/null +++ b/sentry-ruby/spec/sentry/log_event_spec.rb @@ -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 diff --git a/sentry-ruby/spec/sentry/transport_spec.rb b/sentry-ruby/spec/sentry/transport_spec.rb index 372fbf636..5ffc0e24c 100644 --- a/sentry-ruby/spec/sentry/transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport_spec.rb @@ -237,6 +237,76 @@ end end + context "log events" do + let(:log_events) do + 5.times.map do |i| + Sentry::LogEvent.new( + configuration: configuration, + level: :info, + body: "User John has logged in!", + trace_id: "5b8efff798038103d269b633813fc60c", + timestamp: 1544719860.0, + attributes: { + "sentry.message.template" => "User %s has logged in!", + "sentry.message.parameters.0" => "John", + "sentry.environment" => "production", + "sentry.release" => "1.0.0", + "sentry.trace.parent_span_id" => "b0e6f15b45c36b12" + } + ) + end + end + + let(:envelope) do + envelope = Sentry::Envelope.new + + envelope.add_item( + { + type: "log", + item_count: log_events.size, + content_type: "application/vnd.sentry.items.log+json" + }, + { items: log_events.map(&:to_hash) } + ) + + envelope + end + + it "generates correct envelope content for log events" do + result, _ = subject.serialize_envelope(envelope) + + envelope_header, item_header, item_payload = result.split("\n") + + envelope_header_parsed = JSON.parse(envelope_header) + expect(envelope_header_parsed).to be_a(Hash) + + item_header_parsed = JSON.parse(item_header) + expect(item_header_parsed).to eq({ + "type" => "log", + "item_count" => 5, + "content_type" => "application/vnd.sentry.items.log+json" + }) + + item_payload_parsed = JSON.parse(item_payload) + expect(item_payload_parsed).to have_key("items") + expect(item_payload_parsed["items"].size).to eq(5) + + log_event = item_payload_parsed["items"].first + expect(log_event["level"]).to eq("info") + expect(log_event["body"]).to eq("User John has logged in!") + expect(log_event["timestamp"]).to be_a(Float) + + expect(log_event["attributes"]).to include( + "sentry.message.template" => { "value" => "User %s has logged in!", "type" => "string" }, + "sentry.message.parameters.0" => { "value" => "John", "type" => "string" }, + "sentry.environment" => { "value" => "development", "type" => "string" }, + "sentry.release" => { "value" => "1.0.0", "type" => "string" }, + "sentry.trace.parent_span_id" => { "value" => "b0e6f15b45c36b12", "type" => "string" }, + "sentry.address" => { "value" => matching(/\w+/), "type" => "string" } + ) + end + end + context "malformed breadcrumb" do let(:event) { client.event_from_message("foo") } @@ -502,6 +572,51 @@ expect(io.string).to match(/Sending envelope with items \[event, attachment, attachment\]/) end end + + context "log events" do + let(:log_events) do + 5.times.map do |i| + Sentry::LogEvent.new( + configuration: configuration, + level: :info, + body: "User John has logged in!", + trace_id: "5b8efff798038103d269b633813fc60c", + timestamp: 1544719860.0, + attributes: { + "sentry.message.template" => "User %s has logged in!", + "sentry.message.parameters.0" => "John", + "sentry.environment" => "production", + "sentry.release" => "1.0.0", + "sentry.trace.parent_span_id" => "b0e6f15b45c36b12" + } + ) + end + end + + let(:envelope) do + envelope = Sentry::Envelope.new + + # Add log item header + envelope.add_item( + { + type: "log", + item_count: log_events.size, + content_type: "application/vnd.sentry.items.log+json" + }, + { items: log_events.map(&:to_hash) } + ) + + envelope + end + + it "sends the log events and logs the action" do + expect(subject).to receive(:send_data) + + subject.send_envelope(envelope) + + expect(io.string).to match(/Sending envelope with items \[log\]/) + end + end end describe "#send_event" do diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index b09afaef4..bc9e6937c 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -352,6 +352,52 @@ end end + describe ".capture_log" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + end + end + + it "sends a log event with trace_id" do + expect do + Sentry.with_scope do |scope| + described_class.capture_log("Test", level: :info, tags: { foo: "baz" }) + end + end.to change { sentry_events.count }.by(1) + + log_event = sentry_events.first + + expect(log_event.type).to eql("log") + expect(log_event.level).to eq(:info) + expect(log_event.attributes).to eql({ tags: { foo: "baz" } }) + + hash = log_event.to_hash + expect(hash[:trace_id]).to_not be(nil) + expect(hash[:attributes]).to_not have_key("sentry.trace.parent_span_id") + end + + it "sends a log event with parent_span_id" do + transaction = Sentry.start_transaction(name: "test_transaction", op: "test.op") + span = transaction.start_child(op: "child span") + + Sentry.get_current_scope.set_span(span) + + described_class.capture_log("Test", level: :info, tags: { foo: "baz" }) + + transaction.finish + + log_event = sentry_events.first + + expect(log_event.type).to eql("log") + expect(log_event.level).to eq(:info) + expect(log_event.attributes).to eql({ tags: { foo: "baz" } }) + + hash = log_event.to_hash + expect(hash[:trace_id]).to eq(transaction.trace_id) + expect(hash[:attributes]["sentry.trace.parent_span_id"]).to eql({ value: transaction.span_id, type: "string" }) + end + end describe ".start_transaction" do describe "sampler example" do