txlemetry-rails 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +48 -0
- data/NOTICE +5 -0
- data/lib/generators/txlemetry/install_generator.rb +39 -0
- data/lib/generators/txlemetry/templates/txlemetry.rb +186 -0
- data/lib/txlemetry/rails/active_job.rb +118 -0
- data/lib/txlemetry/rails/capture_exceptions.rb +170 -0
- data/lib/txlemetry/rails/configuration.rb +120 -0
- data/lib/txlemetry/rails/error_subscriber.rb +62 -0
- data/lib/txlemetry/rails/facade.rb +104 -0
- data/lib/txlemetry/rails/logs/appender.rb +262 -0
- data/lib/txlemetry/rails/logs/rate_limiter.rb +48 -0
- data/lib/txlemetry/rails/logs/setup.rb +261 -0
- data/lib/txlemetry/rails/logs/severity.rb +49 -0
- data/lib/txlemetry/rails/parameter_filter.rb +136 -0
- data/lib/txlemetry/rails/railtie.rb +356 -0
- data/lib/txlemetry/rails/request_context.rb +63 -0
- data/lib/txlemetry/rails/request_metadata.rb +84 -0
- data/lib/txlemetry/rails/rescued_exception_interceptor.rb +37 -0
- data/lib/txlemetry/rails/tracing_headers.rb +84 -0
- data/lib/txlemetry/rails/version.rb +7 -0
- data/lib/txlemetry/rails.rb +130 -0
- data/lib/txlemetry-rails.rb +11 -0
- metadata +91 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Portions of this file are derived from getsentry/sentry-ruby
|
|
4
|
+
# Copyright (c) 2020 Sentry
|
|
5
|
+
# Licensed under the MIT License: https://github.com/getsentry/sentry-ruby/blob/master/LICENSE
|
|
6
|
+
|
|
7
|
+
module Txlemetry
|
|
8
|
+
module Rails
|
|
9
|
+
class Configuration
|
|
10
|
+
# Default cap on log records forwarded to Txlemetry Logs per minute.
|
|
11
|
+
DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE = 6_000
|
|
12
|
+
|
|
13
|
+
# @return [Boolean] Whether to automatically capture exceptions from Rails. Defaults to false.
|
|
14
|
+
attr_accessor :auto_capture_exceptions
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] Whether to capture exceptions that Rails rescues (e.g., with rescue_from). Defaults to false.
|
|
17
|
+
attr_accessor :report_rescued_exceptions
|
|
18
|
+
|
|
19
|
+
# @return [Boolean] Whether to automatically instrument ActiveJob. Defaults to false.
|
|
20
|
+
attr_accessor :auto_instrument_active_job
|
|
21
|
+
|
|
22
|
+
# @return [Array<String>] Exception class names to ignore in addition to the defaults.
|
|
23
|
+
attr_accessor :excluded_exceptions
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] Whether to use Txlemetry tracing headers for request-scoped identity/session context.
|
|
26
|
+
# Defaults to true.
|
|
27
|
+
attr_accessor :use_tracing_headers
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] Whether to capture the current user context in exceptions. Defaults to true.
|
|
30
|
+
attr_accessor :capture_user_context
|
|
31
|
+
|
|
32
|
+
# @return [Symbol] Method name to call on controller to get the current user. Defaults to :current_user.
|
|
33
|
+
attr_accessor :current_user_method
|
|
34
|
+
|
|
35
|
+
# @return [Proc, nil] Callable used to resolve the current user. When set, this takes precedence over
|
|
36
|
+
# current_user_method. The callable may accept the controller instance or no arguments.
|
|
37
|
+
attr_accessor :current_user_resolver
|
|
38
|
+
|
|
39
|
+
# @return [Symbol, nil] Method name to call on the user object to get distinct_id. When nil, tries:
|
|
40
|
+
# txlemetry_distinct_id, distinct_id, id, pk, uuid in order.
|
|
41
|
+
attr_accessor :user_id_method
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] Master switch for forwarding logs to Txlemetry Logs over OTLP. Defaults to false.
|
|
44
|
+
attr_accessor :logs_enabled
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] Whether to broadcast Rails.logger output into the Txlemetry Logs sink. Defaults to true
|
|
47
|
+
# (only takes effect when {#logs_enabled} is true).
|
|
48
|
+
attr_accessor :logs_forward_rails_logger
|
|
49
|
+
|
|
50
|
+
# @return [Integer, Symbol, nil] Minimum severity to forward to Txlemetry Logs. When nil, inherits the
|
|
51
|
+
# current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info).
|
|
52
|
+
attr_accessor :logs_level
|
|
53
|
+
|
|
54
|
+
# @return [Integer, String, nil] Maximum log records forwarded to Txlemetry Logs per minute, protecting
|
|
55
|
+
# the ingestion quota from runaway log volume. Defaults to 6000. Numeric strings (e.g. from ENV) are
|
|
56
|
+
# coerced. Set to nil, 0, or a negative value to disable the cap; an unparseable value falls back to
|
|
57
|
+
# the default with a warning.
|
|
58
|
+
attr_accessor :logs_max_records_per_minute
|
|
59
|
+
|
|
60
|
+
# @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity, :body,
|
|
61
|
+
# :attributes — where :severity is a symbol such as :warn) before it is sent to Txlemetry Logs.
|
|
62
|
+
# Return a (possibly modified) hash to send, or nil to drop the record — useful for scrubbing
|
|
63
|
+
# PII. If the callback raises, the record is dropped. Defaults to nil.
|
|
64
|
+
attr_accessor :logs_before_send
|
|
65
|
+
|
|
66
|
+
# @return [Txlemetry::Rails::Configuration]
|
|
67
|
+
def initialize
|
|
68
|
+
@auto_capture_exceptions = false
|
|
69
|
+
@report_rescued_exceptions = false
|
|
70
|
+
@auto_instrument_active_job = false
|
|
71
|
+
@excluded_exceptions = []
|
|
72
|
+
@use_tracing_headers = true
|
|
73
|
+
@capture_user_context = true
|
|
74
|
+
@current_user_method = :current_user
|
|
75
|
+
@current_user_resolver = nil
|
|
76
|
+
@user_id_method = nil
|
|
77
|
+
@logs_enabled = false
|
|
78
|
+
@logs_forward_rails_logger = true
|
|
79
|
+
@logs_level = nil
|
|
80
|
+
@logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE
|
|
81
|
+
@logs_before_send = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Default exceptions that Rails apps typically don't want to track.
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<String>]
|
|
87
|
+
def default_excluded_exceptions
|
|
88
|
+
[
|
|
89
|
+
'AbstractController::ActionNotFound',
|
|
90
|
+
'ActionController::BadRequest',
|
|
91
|
+
'ActionController::InvalidAuthenticityToken',
|
|
92
|
+
'ActionController::InvalidCrossOriginRequest',
|
|
93
|
+
'ActionController::MethodNotAllowed',
|
|
94
|
+
'ActionController::NotImplemented',
|
|
95
|
+
'ActionController::ParameterMissing',
|
|
96
|
+
'ActionController::RoutingError',
|
|
97
|
+
'ActionController::UnknownFormat',
|
|
98
|
+
'ActionController::UnknownHttpMethod',
|
|
99
|
+
'ActionDispatch::Http::MimeNegotiation::InvalidType',
|
|
100
|
+
'ActionDispatch::Http::Parameters::ParseError',
|
|
101
|
+
'ActiveRecord::RecordNotFound',
|
|
102
|
+
'ActiveRecord::RecordNotUnique'
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param exception [Exception] The exception to check.
|
|
107
|
+
# @return [Boolean] Whether the exception should be captured.
|
|
108
|
+
def should_capture_exception?(exception)
|
|
109
|
+
exception_name = exception.class.name
|
|
110
|
+
!all_excluded_exceptions.include?(exception_name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def all_excluded_exceptions
|
|
116
|
+
default_excluded_exceptions + excluded_exceptions
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/rails/parameter_filter'
|
|
4
|
+
|
|
5
|
+
module Txlemetry
|
|
6
|
+
module Rails
|
|
7
|
+
# Rails 7.0+ error reporter integration.
|
|
8
|
+
# This integrates with Rails.error.handle and Rails.error.record.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class ErrorSubscriber
|
|
12
|
+
include ParameterFilter
|
|
13
|
+
|
|
14
|
+
# @param error [Exception] Error reported by Rails.
|
|
15
|
+
# @param handled [Boolean]
|
|
16
|
+
# @param severity [Symbol, String]
|
|
17
|
+
# @param context [Hash]
|
|
18
|
+
# @param source [String, nil]
|
|
19
|
+
# @return [void]
|
|
20
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
21
|
+
return unless Txlemetry::Rails.config&.auto_capture_exceptions
|
|
22
|
+
return unless Txlemetry::Rails.config&.should_capture_exception?(error)
|
|
23
|
+
# Skip if in a web request - CaptureExceptions middleware will handle it
|
|
24
|
+
# with richer context (URL, params, controller, etc.)
|
|
25
|
+
return if Txlemetry::Rails.in_web_request?
|
|
26
|
+
# ActionDispatch::Executor reports the exception once the response has
|
|
27
|
+
# unwound past our middleware, so the flag above is already cleared.
|
|
28
|
+
# CaptureExceptions marks what it captured to catch that late report.
|
|
29
|
+
return if Txlemetry::Rails.web_exception_captured?(error)
|
|
30
|
+
return if Txlemetry::Rails.config&.auto_instrument_active_job &&
|
|
31
|
+
Txlemetry::Rails.active_job_exception_captured?(error)
|
|
32
|
+
|
|
33
|
+
distinct_id = context[:user_id] || context[:distinct_id]
|
|
34
|
+
|
|
35
|
+
properties = {
|
|
36
|
+
'$exception_source' => source || 'rails_error_reporter',
|
|
37
|
+
'$exception_handled' => handled,
|
|
38
|
+
'$exception_severity' => severity.to_s
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Add context information (safely serialized to avoid circular references)
|
|
42
|
+
if context.present?
|
|
43
|
+
context.each do |key, value|
|
|
44
|
+
next if key.in?(%i[user_id distinct_id])
|
|
45
|
+
|
|
46
|
+
properties["$context_#{key}"] = safe_serialize(value)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Txlemetry.capture_exception(
|
|
51
|
+
error,
|
|
52
|
+
distinct_id,
|
|
53
|
+
properties,
|
|
54
|
+
mechanism: { 'type' => 'rails_error_reporter', 'handled' => handled }
|
|
55
|
+
)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Txlemetry::Logging.logger.error("Failed to report error via subscriber: #{e.message}")
|
|
58
|
+
Txlemetry::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/rails/version'
|
|
4
|
+
|
|
5
|
+
module Txlemetry
|
|
6
|
+
module Rails
|
|
7
|
+
# Install the Rails singleton-style Txlemetry facade at load time so Rails app
|
|
8
|
+
# initializers can call Txlemetry.init before Railtie initializers run.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
# @return [void]
|
|
12
|
+
def self.install_txlemetry_facade!
|
|
13
|
+
return if @txlemetry_facade_installed
|
|
14
|
+
|
|
15
|
+
Txlemetry.class_eval do
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :client
|
|
18
|
+
|
|
19
|
+
# Initialize the singleton Txlemetry client used by Rails delegators.
|
|
20
|
+
#
|
|
21
|
+
# @param options [Hash] Core {Txlemetry::Client} options.
|
|
22
|
+
# @yieldparam config [Txlemetry::Rails::InitConfig] Block-based core SDK configuration.
|
|
23
|
+
# @return [Txlemetry::Client]
|
|
24
|
+
def init(options = {})
|
|
25
|
+
# If block given, yield to configuration
|
|
26
|
+
if block_given?
|
|
27
|
+
config = Txlemetry::Rails::InitConfig.new(options)
|
|
28
|
+
yield config
|
|
29
|
+
options = config.to_client_options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Let the Txlemetry Logs pipeline reuse the same api_key/host without
|
|
33
|
+
# the core client exposing public readers.
|
|
34
|
+
Txlemetry::Rails::Logs::Setup.remember_client_options(options) if defined?(Txlemetry::Rails::Logs::Setup)
|
|
35
|
+
|
|
36
|
+
# Create the Txlemetry client. If a client already exists, shut it down
|
|
37
|
+
# after replacement so repeated init calls do not leave background
|
|
38
|
+
# resources from the previous instance running.
|
|
39
|
+
previous_client = @client
|
|
40
|
+
@client = Txlemetry::Client.new(
|
|
41
|
+
options.merge(_lib: 'txlemetry-rails', _lib_version: Txlemetry::Rails::VERSION)
|
|
42
|
+
)
|
|
43
|
+
begin
|
|
44
|
+
previous_client&.shutdown
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Txlemetry::Logging.logger.warn("Failed to shut down previous Txlemetry client: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
@client
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] Whether {Txlemetry.init} has created a client.
|
|
52
|
+
def initialized?
|
|
53
|
+
!@client.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fallback for any client methods not explicitly defined.
|
|
57
|
+
#
|
|
58
|
+
# @api private
|
|
59
|
+
def method_missing(method_name, ...)
|
|
60
|
+
ensure_initialized!
|
|
61
|
+
|
|
62
|
+
if client.respond_to?(method_name)
|
|
63
|
+
client.public_send(method_name, ...)
|
|
64
|
+
else
|
|
65
|
+
super
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
71
|
+
ensure_initialized!
|
|
72
|
+
client.respond_to?(method_name, include_private) || super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def ensure_initialized!
|
|
78
|
+
return if initialized?
|
|
79
|
+
|
|
80
|
+
@client = Txlemetry::Client.new(api_key: nil, silence_disabled_client_error: true)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
%i[
|
|
86
|
+
capture
|
|
87
|
+
capture_exception
|
|
88
|
+
identify
|
|
89
|
+
alias
|
|
90
|
+
group_identify
|
|
91
|
+
is_feature_enabled
|
|
92
|
+
get_feature_flag
|
|
93
|
+
get_all_flags
|
|
94
|
+
].each do |method_name|
|
|
95
|
+
Txlemetry.define_singleton_method(method_name) do |*args, **kwargs, &block|
|
|
96
|
+
ensure_initialized!
|
|
97
|
+
client.public_send(method_name, *args, **kwargs, &block)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@txlemetry_facade_installed = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'txlemetry/internal/context'
|
|
6
|
+
require 'txlemetry/logging'
|
|
7
|
+
require 'txlemetry/rails/logs/severity'
|
|
8
|
+
|
|
9
|
+
module Txlemetry
|
|
10
|
+
module Rails
|
|
11
|
+
module Logs
|
|
12
|
+
# A `Logger`-compatible sink that forwards each log record to an
|
|
13
|
+
# OpenTelemetry logger as an OTLP log record.
|
|
14
|
+
#
|
|
15
|
+
# It is designed to be broadcast alongside the app's existing
|
|
16
|
+
# `Rails.logger` so that ordinary `Rails.logger.info(...)` calls flow to
|
|
17
|
+
# Txlemetry Logs in addition to the normal output. Each record is stamped
|
|
18
|
+
# with the request-scoped Txlemetry identity captured by
|
|
19
|
+
# {Txlemetry::Rails::RequestContext}.
|
|
20
|
+
#
|
|
21
|
+
# Thread-safety: intentionally lock-free apart from the optional rate
|
|
22
|
+
# limiter's counter. Emitting touches no shared mutable state
|
|
23
|
+
# (`@otel_logger` is assigned once, attributes are built per call, and
|
|
24
|
+
# `Internal::Context.current` is thread/fiber-local), and the OTel
|
|
25
|
+
# BatchLogRecordProcessor synchronizes its buffer internally — the same
|
|
26
|
+
# split as stdlib `Logger`, which locks in `LogDevice`, not
|
|
27
|
+
# `Logger#add`. A mutex around emit would serialize all app logging
|
|
28
|
+
# needlessly.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
class Appender < ::Logger
|
|
32
|
+
SELF_LOG_PREFIX = '[txlemetry-ruby]'
|
|
33
|
+
SELF_LOG_PROGNAME = 'Txlemetry'
|
|
34
|
+
# Maps Txlemetry event-property names (as stored in Internal::Context) to
|
|
35
|
+
# the OTel semantic-convention attribute names used on log records,
|
|
36
|
+
# matching the web SDK so one filter works across SDKs.
|
|
37
|
+
REQUEST_ATTRIBUTE_NAMES = {
|
|
38
|
+
'$current_url' => 'url.full',
|
|
39
|
+
'$request_method' => 'http.request.method',
|
|
40
|
+
'$request_path' => 'url.path'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# @param otel_logger [#on_emit] An OpenTelemetry logger.
|
|
44
|
+
# @param level [Integer, nil] Minimum severity to forward.
|
|
45
|
+
# @param rate_limiter [Txlemetry::Rails::Logs::RateLimiter, nil] Optional cap on
|
|
46
|
+
# forwarded records, protecting the ingestion quota from runaway log volume.
|
|
47
|
+
# @param before_send [#call, nil] Optional callback invoked with each record hash
|
|
48
|
+
# (:timestamp, :severity, :body, :attributes — where :severity is a symbol such
|
|
49
|
+
# as :warn) before it is emitted. Return a (possibly modified) hash to send, or
|
|
50
|
+
# nil to drop — useful for scrubbing PII. If the callback raises, the record is
|
|
51
|
+
# dropped.
|
|
52
|
+
def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil)
|
|
53
|
+
super(nil)
|
|
54
|
+
@otel_logger = otel_logger
|
|
55
|
+
@rate_limiter = rate_limiter
|
|
56
|
+
@before_send = before_send
|
|
57
|
+
# The forwarding threshold deliberately does NOT live in Logger#level.
|
|
58
|
+
# Rails 7.1+ BroadcastLogger computes #level as the min and #debug?
|
|
59
|
+
# etc. as the any? across sinks, so storing it there would widen the
|
|
60
|
+
# app-wide predicates (logs_level = :debug would flip
|
|
61
|
+
# Rails.logger.debug? true and make e.g. ActiveRecord start
|
|
62
|
+
# generating SQL debug lines), and a broadcast-wide
|
|
63
|
+
# `Rails.logger.level =` would clobber the configured logs_level.
|
|
64
|
+
# Pinning the inherited level to UNKNOWN keeps this sink invisible
|
|
65
|
+
# to those calculations; filtering happens against @threshold in #add.
|
|
66
|
+
@threshold = level || ::Logger::DEBUG
|
|
67
|
+
self.level = ::Logger::UNKNOWN
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Re-entrancy guard key. Fiber-local (Thread.current[]), which is what
|
|
71
|
+
# recursion needs: if anything inside #add logs through a broadcast
|
|
72
|
+
# that includes this appender (e.g. a logs_before_send callback calling
|
|
73
|
+
# Rails.logger), the nested call would recurse until SystemStackError —
|
|
74
|
+
# which, as an Exception, escapes the rescue below and breaks the app.
|
|
75
|
+
REENTRANCY_KEY = :txlemetry_rails_logs_emitting
|
|
76
|
+
|
|
77
|
+
# Mirrors `Logger#add` message/progname resolution, then emits to OTel
|
|
78
|
+
# instead of writing to a log device.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] Always true so it composes with broadcast loggers.
|
|
81
|
+
def add(severity, message = nil, progname = nil)
|
|
82
|
+
return true if Thread.current[REENTRANCY_KEY]
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
Thread.current[REENTRANCY_KEY] = true
|
|
86
|
+
|
|
87
|
+
severity ||= ::Logger::UNKNOWN
|
|
88
|
+
return true if severity < @threshold
|
|
89
|
+
|
|
90
|
+
if message.nil?
|
|
91
|
+
if block_given?
|
|
92
|
+
message = yield
|
|
93
|
+
else
|
|
94
|
+
message = progname
|
|
95
|
+
progname = nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return true if message.nil?
|
|
100
|
+
return true if self_log?(message, progname)
|
|
101
|
+
|
|
102
|
+
record = apply_before_send(build_record(severity, message, progname))
|
|
103
|
+
return true if record.nil?
|
|
104
|
+
|
|
105
|
+
case @rate_limiter&.record
|
|
106
|
+
when :reject
|
|
107
|
+
return true
|
|
108
|
+
when :reject_first
|
|
109
|
+
emit_rate_cap_notice
|
|
110
|
+
return true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
emit(record)
|
|
114
|
+
true
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
# Never let log forwarding break the calling code path, but leave
|
|
117
|
+
# one breadcrumb: a persistent emit failure would otherwise drop
|
|
118
|
+
# 100% of records with no signal anywhere.
|
|
119
|
+
warn_emit_error(e)
|
|
120
|
+
true
|
|
121
|
+
ensure
|
|
122
|
+
Thread.current[REENTRANCY_KEY] = nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def build_record(severity, message, progname)
|
|
129
|
+
{
|
|
130
|
+
timestamp: Time.now,
|
|
131
|
+
severity: Severity.name_for(severity),
|
|
132
|
+
body: body_for(message),
|
|
133
|
+
attributes: attributes_for(progname)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def emit(record)
|
|
138
|
+
# The before_send callback sees a single :severity enum; the OTel
|
|
139
|
+
# number/text pair is derived here so the two can never be set
|
|
140
|
+
# inconsistently.
|
|
141
|
+
severity_number, severity_text = Severity.for_name(record[:severity])
|
|
142
|
+
@otel_logger.on_emit(
|
|
143
|
+
timestamp: record[:timestamp],
|
|
144
|
+
severity_number: severity_number,
|
|
145
|
+
severity_text: severity_text,
|
|
146
|
+
body: record[:body],
|
|
147
|
+
attributes: record[:attributes]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# One discoverable notice per window so truncation isn't silent. Emitted
|
|
152
|
+
# directly (bypassing before_send) so a scrubber can't accidentally
|
|
153
|
+
# suppress the only signal that records are being dropped.
|
|
154
|
+
def emit_rate_cap_notice
|
|
155
|
+
emit(
|
|
156
|
+
timestamp: Time.now,
|
|
157
|
+
severity: :warn,
|
|
158
|
+
body: "Txlemetry Logs rate cap reached (#{@rate_limiter.limit} records/minute); " \
|
|
159
|
+
'dropping further records for the remainder of this window',
|
|
160
|
+
attributes: {}
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Runs before the rate-cap check (matching the other Txlemetry SDKs) so
|
|
165
|
+
# records dropped by the callback never consume window budget — a
|
|
166
|
+
# before_send that drops noisy logs must not starve the legitimate
|
|
167
|
+
# records behind them.
|
|
168
|
+
#
|
|
169
|
+
# A failing callback drops the record: the likeliest use is PII
|
|
170
|
+
# scrubbing, where shipping the unscrubbed original is worse than losing
|
|
171
|
+
# the line.
|
|
172
|
+
def apply_before_send(record)
|
|
173
|
+
return record unless @before_send
|
|
174
|
+
|
|
175
|
+
result = @before_send.call(record)
|
|
176
|
+
return result if result.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
# nil is an intentional drop and stays silent; any other type is
|
|
179
|
+
# likely a bug (e.g. a proc whose last expression isn't the record).
|
|
180
|
+
warn_before_send("returned #{result.class} instead of a Hash or nil") unless result.nil?
|
|
181
|
+
nil
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
warn_before_send("raised (#{e.class}: #{e.message})")
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def warn_before_send(description)
|
|
188
|
+
# Benign race: concurrent first failures may warn more than once.
|
|
189
|
+
return if @before_send_warned
|
|
190
|
+
|
|
191
|
+
@before_send_warned = true
|
|
192
|
+
Txlemetry::Logging.logger.warn("logs_before_send #{description}; dropping the record")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def warn_emit_error(error)
|
|
196
|
+
# Benign race: concurrent first failures may warn more than once.
|
|
197
|
+
return if @emit_error_warned
|
|
198
|
+
|
|
199
|
+
@emit_error_warned = true
|
|
200
|
+
Txlemetry::Logging.logger.warn(
|
|
201
|
+
"Txlemetry Logs failed to emit a record (#{error.class}: #{error.message}); " \
|
|
202
|
+
'further failures will be dropped silently'
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def body_for(message)
|
|
207
|
+
str = stringify(message)
|
|
208
|
+
str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8
|
|
209
|
+
str.valid_encoding? ? str : str.scrub
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Mirrors stdlib Logger::Formatter#msg2str so the body matches what the
|
|
213
|
+
# file logger writes — most importantly, an Exception renders with its
|
|
214
|
+
# class, message, and backtrace instead of a backtrace-less #inspect.
|
|
215
|
+
# Array() guards a never-raised exception whose backtrace is nil. The
|
|
216
|
+
# returned String is always freshly built (dup/interpolation/inspect)
|
|
217
|
+
# so a logs_before_send callback can mutate it without touching frozen
|
|
218
|
+
# app strings.
|
|
219
|
+
def stringify(message)
|
|
220
|
+
case message
|
|
221
|
+
when String
|
|
222
|
+
message.dup
|
|
223
|
+
when Exception
|
|
224
|
+
"#{message.message} (#{message.class})\n#{Array(message.backtrace).join("\n")}"
|
|
225
|
+
else
|
|
226
|
+
message.inspect
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def attributes_for(progname)
|
|
231
|
+
attributes = {}
|
|
232
|
+
# Ruby's progname is the closest analog to the OTel-world "logger name";
|
|
233
|
+
# logger.name is the key users coming from other ecosystems will expect.
|
|
234
|
+
attributes['logger.name'] = progname.to_s if progname
|
|
235
|
+
|
|
236
|
+
context = Internal::Context.current
|
|
237
|
+
return attributes unless context
|
|
238
|
+
|
|
239
|
+
attributes['posthogDistinctId'] = context.distinct_id if context.distinct_id
|
|
240
|
+
attributes['sessionId'] = context.session_id if context.session_id
|
|
241
|
+
|
|
242
|
+
properties = context.properties || {}
|
|
243
|
+
REQUEST_ATTRIBUTE_NAMES.each do |key, attribute_name|
|
|
244
|
+
value = properties[key] || properties[key.to_sym]
|
|
245
|
+
attributes[attribute_name] = value if value
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
attributes
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self_log?(message, progname)
|
|
252
|
+
return true if progname.to_s == SELF_LOG_PROGNAME
|
|
253
|
+
|
|
254
|
+
# PrefixedLogger always places the prefix at the start of the message,
|
|
255
|
+
# so start_with? suffices and avoids suppressing app logs that merely
|
|
256
|
+
# mention the SDK mid-string.
|
|
257
|
+
message.is_a?(String) && message.start_with?(SELF_LOG_PREFIX)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txlemetry
|
|
4
|
+
module Rails
|
|
5
|
+
module Logs
|
|
6
|
+
# Fixed-window rate limiter protecting the Txlemetry Logs ingestion quota
|
|
7
|
+
# from runaway log volume (Txlemetry Logs bills by data ingested).
|
|
8
|
+
#
|
|
9
|
+
# Thread-safe: the counter is the one piece of shared mutable state in
|
|
10
|
+
# the logs pipeline, guarded by a mutex scoped to a counter bump.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class RateLimiter
|
|
14
|
+
WINDOW_SECONDS = 60
|
|
15
|
+
|
|
16
|
+
# @return [Integer] Maximum records allowed per window.
|
|
17
|
+
attr_reader :limit
|
|
18
|
+
|
|
19
|
+
# @param limit [Integer] Maximum records allowed per {WINDOW_SECONDS} window.
|
|
20
|
+
def initialize(limit)
|
|
21
|
+
@limit = limit
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@window = nil
|
|
24
|
+
@count = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Records one attempt and returns the verdict.
|
|
28
|
+
#
|
|
29
|
+
# @return [Symbol] :allow when under the cap, :reject_first for the
|
|
30
|
+
# first rejection of a window (callers may emit a single notice),
|
|
31
|
+
# :reject thereafter.
|
|
32
|
+
def record
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
window = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i / WINDOW_SECONDS
|
|
35
|
+
if window != @window
|
|
36
|
+
@window = window
|
|
37
|
+
@count = 0
|
|
38
|
+
end
|
|
39
|
+
@count += 1
|
|
40
|
+
next :allow if @count <= @limit
|
|
41
|
+
|
|
42
|
+
@count == @limit + 1 ? :reject_first : :reject
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|