posthog-rails 3.11.1 → 3.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 986306ebedb5f7c0f06df369401a65a717eba7644285798a52152ef2a56748e9
4
- data.tar.gz: 2ebc32708ed7a022ee45aed29abdee657843374e979633f4f5ab56777a8a5262
3
+ metadata.gz: d01a764e36c2aca698e1757342abddd1448406529c9c5c7c41ccce2b1b538212
4
+ data.tar.gz: b7df5045175278be04c07f1367cbe9a487e7339185ae6f769712385fb5f16d14
5
5
  SHA512:
6
- metadata.gz: 48c9c6daf2ae309243f04e62093152d59acbc772968fd241634b28a0948721ab9626fbe4db15f83aae03c73bec43d3d643237a3054e063f41b020a028224ba4c
7
- data.tar.gz: 6ce744037de3fe24245ae3bae7f6b111647abc0118147d49ba9b10ed17af823b58db3894ac750bbcec73b30a18a713a9fbd09d78fbfd1aa2559e5b090ccee867
6
+ metadata.gz: 168df95b6ef66e4a4b75d049f2a69971a47e3cd078d2dbe32f4181268021350f9ae5ad7a187ea57a6b8f019b6147db45ccd400cb1002c5b2a650c8c84776c849
7
+ data.tar.gz: 521167581b9e7b7f2ed5db13af4f15199955e598550335fa67d1dd564254dbadc07c396663e7953c608de6ff9548cefa33b6091a0d59ce6e73556ed753d57b55
@@ -23,6 +23,14 @@ module Posthog
23
23
  say ' - POSTHOG_API_KEY (required)'
24
24
  say ' - POSTHOG_PERSONAL_API_KEY (optional, for feature flags)'
25
25
  say ''
26
+ say 'Optional: forward Rails.logger to PostHog Logs', :yellow
27
+ say ' - Add to your Gemfile (requires Ruby 3.3+):'
28
+ say " gem 'opentelemetry-sdk', require: false"
29
+ say " gem 'opentelemetry-logs-sdk', '>= 0.6.0', require: false"
30
+ say " gem 'opentelemetry-exporter-otlp-logs', require: false"
31
+ say ' - Set config.logs_enabled = true in the initializer'
32
+ say ' - Docs: https://posthog.com/docs/logs'
33
+ say ''
26
34
  say 'For more information, see: https://posthog.com/docs/libraries/ruby'
27
35
  say ''
28
36
  end
@@ -43,6 +43,48 @@ PostHog::Rails.configure do |config|
43
43
  # # 'MyCustom404Error',
44
44
  # # 'MyCustomValidationError'
45
45
  # ]
46
+
47
+ # --------------------------------------------------------------------------
48
+ # POSTHOG LOGS (OpenTelemetry) - opt-in
49
+ # --------------------------------------------------------------------------
50
+ # Forward Rails.logger output to PostHog Logs over OTLP, automatically
51
+ # correlated with the request's distinct_id and session_id.
52
+ #
53
+ # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile. Use
54
+ # require: false — posthog-rails loads them only when logs are enabled.
55
+ # logs-sdk must be >= 0.6.0 (older versions lack LogRecordData#event_name,
56
+ # which the OTLP exporter needs, raising NoMethodError on export):
57
+ # gem 'opentelemetry-sdk', require: false
58
+ # gem 'opentelemetry-logs-sdk', '>= 0.6.0', require: false
59
+ # gem 'opentelemetry-exporter-otlp-logs', require: false
60
+ #
61
+ # Enable log forwarding (default: false)
62
+ # config.logs_enabled = true
63
+
64
+ # Broadcast Rails.logger into PostHog Logs (default: true when logs enabled)
65
+ # config.logs_forward_rails_logger = true
66
+
67
+ # Minimum severity to forward; nil inherits Rails.logger's level (default: nil)
68
+ # config.logs_level = :info
69
+
70
+ # Maximum records forwarded per minute, protecting your ingestion quota from
71
+ # runaway log volume. Numeric strings (e.g. from ENV) are coerced.
72
+ # (default: 6000; set to nil or 0 to disable the cap)
73
+ # config.logs_max_records_per_minute = 6_000
74
+
75
+ # Modify or drop log records before they are sent, e.g. to scrub PII.
76
+ # Receives a hash (:timestamp, :severity, :body, :attributes — :severity is
77
+ # a symbol such as :warn); return the (modified) hash to send or nil to
78
+ # drop. Records are dropped if the callback raises. (default: nil)
79
+ # config.logs_before_send = proc { |record|
80
+ # next nil if record[:severity] == :debug
81
+ #
82
+ # record[:body] = record[:body].gsub(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/, '[redacted email]')
83
+ # record
84
+ # }
85
+
86
+ # Logs reuse the same project token (api_key) and host configured below, so
87
+ # there is nothing extra to set. Logs are sent to <host>/i/v1/logs.
46
88
  end
47
89
 
48
90
  # You can also configure Rails options directly:
@@ -7,6 +7,9 @@
7
7
  module PostHog
8
8
  module Rails
9
9
  class Configuration
10
+ # Default cap on log records forwarded to PostHog Logs per minute.
11
+ DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE = 6_000
12
+
10
13
  # @return [Boolean] Whether to automatically capture exceptions from Rails. Defaults to false.
11
14
  attr_accessor :auto_capture_exceptions
12
15
 
@@ -33,6 +36,29 @@ module PostHog
33
36
  # posthog_distinct_id, distinct_id, id, pk, uuid in order.
34
37
  attr_accessor :user_id_method
35
38
 
39
+ # @return [Boolean] Master switch for forwarding logs to PostHog Logs over OTLP. Defaults to false.
40
+ attr_accessor :logs_enabled
41
+
42
+ # @return [Boolean] Whether to broadcast Rails.logger output into the PostHog Logs sink. Defaults to true
43
+ # (only takes effect when {#logs_enabled} is true).
44
+ attr_accessor :logs_forward_rails_logger
45
+
46
+ # @return [Integer, Symbol, nil] Minimum severity to forward to PostHog Logs. When nil, inherits the
47
+ # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info).
48
+ attr_accessor :logs_level
49
+
50
+ # @return [Integer, String, nil] Maximum log records forwarded to PostHog Logs per minute, protecting
51
+ # the ingestion quota from runaway log volume. Defaults to 6000. Numeric strings (e.g. from ENV) are
52
+ # coerced. Set to nil, 0, or a negative value to disable the cap; an unparseable value falls back to
53
+ # the default with a warning.
54
+ attr_accessor :logs_max_records_per_minute
55
+
56
+ # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity, :body,
57
+ # :attributes — where :severity is a symbol such as :warn) before it is sent to PostHog Logs.
58
+ # Return a (possibly modified) hash to send, or nil to drop the record — useful for scrubbing
59
+ # PII. If the callback raises, the record is dropped. Defaults to nil.
60
+ attr_accessor :logs_before_send
61
+
36
62
  # @return [PostHog::Rails::Configuration]
37
63
  def initialize
38
64
  @auto_capture_exceptions = false
@@ -43,6 +69,11 @@ module PostHog
43
69
  @capture_user_context = true
44
70
  @current_user_method = :current_user
45
71
  @user_id_method = nil
72
+ @logs_enabled = false
73
+ @logs_forward_rails_logger = true
74
+ @logs_level = nil
75
+ @logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE
76
+ @logs_before_send = nil
46
77
  end
47
78
 
48
79
  # Default exceptions that Rails apps typically don't want to track.
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'time'
5
+ require 'posthog/internal/context'
6
+ require 'posthog/logging'
7
+ require 'posthog/rails/logs/severity'
8
+
9
+ module PostHog
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
+ # PostHog Logs in addition to the normal output. Each record is stamped
18
+ # with the request-scoped PostHog identity captured by
19
+ # {PostHog::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 = '[posthog-ruby]'
33
+ SELF_LOG_PROGNAME = 'PostHog'
34
+ # Maps PostHog 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 [PostHog::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 = :posthog_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: "PostHog 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 PostHog 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
+ # Unlike the events before_send (which sends the original event when the
170
+ # callback raises), a failing callback drops the record: the likeliest
171
+ # use is PII scrubbing, where shipping the unscrubbed original is worse
172
+ # than losing the line.
173
+ def apply_before_send(record)
174
+ return record unless @before_send
175
+
176
+ result = @before_send.call(record)
177
+ return result if result.is_a?(Hash)
178
+
179
+ # nil is an intentional drop and stays silent; any other type is
180
+ # likely a bug (e.g. a proc whose last expression isn't the record).
181
+ warn_before_send("returned #{result.class} instead of a Hash or nil") unless result.nil?
182
+ nil
183
+ rescue StandardError => e
184
+ warn_before_send("raised (#{e.class}: #{e.message})")
185
+ nil
186
+ end
187
+
188
+ def warn_before_send(description)
189
+ # Benign race: concurrent first failures may warn more than once.
190
+ return if @before_send_warned
191
+
192
+ @before_send_warned = true
193
+ PostHog::Logging.logger.warn("logs_before_send #{description}; dropping the record")
194
+ end
195
+
196
+ def warn_emit_error(error)
197
+ # Benign race: concurrent first failures may warn more than once.
198
+ return if @emit_error_warned
199
+
200
+ @emit_error_warned = true
201
+ PostHog::Logging.logger.warn(
202
+ "PostHog Logs failed to emit a record (#{error.class}: #{error.message}); " \
203
+ 'further failures will be dropped silently'
204
+ )
205
+ end
206
+
207
+ def body_for(message)
208
+ str = stringify(message)
209
+ str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8
210
+ str.valid_encoding? ? str : str.scrub
211
+ end
212
+
213
+ # Mirrors stdlib Logger::Formatter#msg2str so the body matches what the
214
+ # file logger writes — most importantly, an Exception renders with its
215
+ # class, message, and backtrace instead of a backtrace-less #inspect.
216
+ # Array() guards a never-raised exception whose backtrace is nil. The
217
+ # returned String is always freshly built (dup/interpolation/inspect)
218
+ # so a logs_before_send callback can mutate it without touching frozen
219
+ # app strings.
220
+ def stringify(message)
221
+ case message
222
+ when String
223
+ message.dup
224
+ when Exception
225
+ "#{message.message} (#{message.class})\n#{Array(message.backtrace).join("\n")}"
226
+ else
227
+ message.inspect
228
+ end
229
+ end
230
+
231
+ def attributes_for(progname)
232
+ attributes = {}
233
+ # Ruby's progname is the closest analog to the OTel-world "logger name";
234
+ # logger.name is the key users coming from other ecosystems will expect.
235
+ attributes['logger.name'] = progname.to_s if progname
236
+
237
+ context = Internal::Context.current
238
+ return attributes unless context
239
+
240
+ attributes['posthogDistinctId'] = context.distinct_id if context.distinct_id
241
+ attributes['sessionId'] = context.session_id if context.session_id
242
+
243
+ properties = context.properties || {}
244
+ REQUEST_ATTRIBUTE_NAMES.each do |key, attribute_name|
245
+ value = properties[key] || properties[key.to_sym]
246
+ attributes[attribute_name] = value if value
247
+ end
248
+
249
+ attributes
250
+ end
251
+
252
+ def self_log?(message, progname)
253
+ return true if progname.to_s == SELF_LOG_PROGNAME
254
+
255
+ # PrefixedLogger always places the prefix at the start of the message,
256
+ # so start_with? suffices and avoids suppressing app logs that merely
257
+ # mention the SDK mid-string.
258
+ message.is_a?(String) && message.start_with?(SELF_LOG_PREFIX)
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module Rails
5
+ module Logs
6
+ # Fixed-window rate limiter protecting the PostHog Logs ingestion quota
7
+ # from runaway log volume (PostHog 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
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'posthog/logging'
5
+ require 'posthog/rails/configuration'
6
+ require 'posthog/rails/logs/appender'
7
+ require 'posthog/rails/logs/rate_limiter'
8
+
9
+ module PostHog
10
+ module Rails
11
+ module Logs
12
+ # Bootstraps the OpenTelemetry logs pipeline that ships PostHog Logs.
13
+ #
14
+ # The OpenTelemetry gems are optional/soft dependencies. They are required
15
+ # lazily here so that apps which do not enable logs (or run on a Ruby
16
+ # version the logs SDK does not support) are unaffected.
17
+ #
18
+ # @api private
19
+ module Setup
20
+ # Bounds the at_exit flush. Without a timeout, the batch processor
21
+ # joins its worker thread unbounded and the exporter retries each
22
+ # batch with backoff — during an outage that can eat the whole
23
+ # SIGTERM grace period and starve the events client of its flush.
24
+ SHUTDOWN_TIMEOUT_SECONDS = 2
25
+
26
+ class << self
27
+ # @return [OpenTelemetry::SDK::Logs::LoggerProvider, nil]
28
+ attr_reader :provider
29
+
30
+ # @return [PostHog::Rails::Logs::Appender, nil]
31
+ attr_reader :appender
32
+
33
+ # Build the logs pipeline and return the broadcastable appender.
34
+ #
35
+ # Idempotent: subsequent calls return the previously built appender
36
+ # (or nil if setup was skipped).
37
+ #
38
+ # @return [PostHog::Rails::Logs::Appender, nil]
39
+ def install
40
+ return @appender if @installed
41
+
42
+ @installed = true
43
+
44
+ # Respect the core client's test_mode: when it is on, the client
45
+ # swaps in a NoopWorker so events never ship, and the logs pipeline
46
+ # should likewise stay off so test suites don't emit real records.
47
+ # Intentional state, so skip quietly (no warning).
48
+ return nil if @client_test_mode
49
+
50
+ return nil unless require_otel_gems
51
+
52
+ config = PostHog::Rails.config
53
+ token = resolve_token
54
+ if token.nil?
55
+ warn_once(
56
+ 'PostHog Logs enabled but no project token could be resolved ' \
57
+ '(set config.api_key or POSTHOG_API_KEY); skipping.'
58
+ )
59
+ return nil
60
+ end
61
+
62
+ @provider = build_provider(token)
63
+ otel_logger = @provider.logger(name: 'posthog-rails', version: PostHog::VERSION)
64
+ level = resolve_level(config.logs_level) || rails_logger_level
65
+ @appender = Appender.new(
66
+ otel_logger,
67
+ level: level,
68
+ rate_limiter: build_rate_limiter(config),
69
+ before_send: config.logs_before_send
70
+ )
71
+ rescue StandardError => e
72
+ warn_once("Failed to initialize PostHog Logs: #{e.message}")
73
+ nil
74
+ end
75
+
76
+ # Shut the pipeline down, flushing buffered records.
77
+ #
78
+ # @param timeout [Numeric] Max seconds to spend; see {SHUTDOWN_TIMEOUT_SECONDS}.
79
+ # @return [void]
80
+ def shutdown(timeout: SHUTDOWN_TIMEOUT_SECONDS)
81
+ @provider&.shutdown(timeout: timeout)
82
+ rescue StandardError => e
83
+ logger.warn("Error shutting down PostHog Logs: #{e.message}")
84
+ end
85
+
86
+ # Remembers the api_key/host the PostHog client was initialized with
87
+ # (called by PostHog.init) so the logs pipeline can reuse them without
88
+ # the core client exposing public readers.
89
+ #
90
+ # @api private
91
+ # @param options [Hash] The options passed to {PostHog::Client.new}.
92
+ # @return [void]
93
+ def remember_client_options(options)
94
+ return unless options.is_a?(Hash)
95
+
96
+ @client_api_key = options[:api_key] || options['api_key']
97
+ @client_host = options[:host] || options['host']
98
+ @client_test_mode = options[:test_mode] || options['test_mode']
99
+ end
100
+
101
+ # Resets memoized state. Intended for tests.
102
+ #
103
+ # @return [void]
104
+ def reset
105
+ @installed = false
106
+ @provider = nil
107
+ @appender = nil
108
+ @warned = false
109
+ @client_api_key = nil
110
+ @client_host = nil
111
+ @client_test_mode = nil
112
+ end
113
+
114
+ private
115
+
116
+ # The logs token is the same project token the core client uses
117
+ # (i.e. config.api_key, captured by PostHog.init), falling back to
118
+ # ENV['POSTHOG_API_KEY'].
119
+ def resolve_token
120
+ normalize(@client_api_key) || normalize(ENV.fetch('POSTHOG_API_KEY', nil))
121
+ end
122
+
123
+ # The logs host follows the core client's configured host (captured by
124
+ # PostHog.init), falling back to ENV['POSTHOG_HOST'] and finally the
125
+ # US cloud endpoint.
126
+ def resolve_host
127
+ normalize(@client_host) ||
128
+ normalize(ENV.fetch('POSTHOG_HOST', nil)) ||
129
+ 'https://us.i.posthog.com'
130
+ end
131
+
132
+ # nil, 0, and negative values intentionally disable the cap. Numeric
133
+ # strings (e.g. from ENV) are coerced — deliberately via Integer()
134
+ # rather than to_i, since "abc".to_i == 0 would silently disable the
135
+ # cap. Unparseable values warn and fall back to the default cap:
136
+ # a misconfiguration should not switch the protection off.
137
+ def build_rate_limiter(config)
138
+ raw = config.logs_max_records_per_minute
139
+ return nil if raw.nil?
140
+
141
+ limit = Integer(raw, exception: false)
142
+ if limit.nil?
143
+ logger.warn(
144
+ "logs_max_records_per_minute=#{raw.inspect} is not a number; using the default cap " \
145
+ "of #{Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE} records/minute"
146
+ )
147
+ limit = Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE
148
+ end
149
+ return nil unless limit.positive?
150
+
151
+ RateLimiter.new(limit)
152
+ end
153
+
154
+ def require_otel_gems
155
+ require 'opentelemetry-sdk'
156
+ require 'opentelemetry-logs-sdk'
157
+ require 'opentelemetry/exporter/otlp_logs'
158
+ true
159
+ rescue LoadError => e
160
+ warn_once(
161
+ "PostHog Logs enabled but the OpenTelemetry gems are missing (#{e.message}). " \
162
+ "Add 'opentelemetry-sdk', 'opentelemetry-logs-sdk', and " \
163
+ "'opentelemetry-exporter-otlp-logs' (each with require: false) to your Gemfile " \
164
+ 'to enable log forwarding.'
165
+ )
166
+ false
167
+ end
168
+
169
+ def build_provider(token)
170
+ resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attributes)
171
+ provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
172
+ exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
173
+ endpoint: logs_endpoint(resolve_host),
174
+ headers: { 'Authorization' => "Bearer #{token}" }
175
+ )
176
+ processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
177
+ provider.add_log_record_processor(processor)
178
+ provider
179
+ end
180
+
181
+ def resource_attributes
182
+ # service.version is intentionally omitted. Per OpenTelemetry semantic
183
+ # conventions it is the deployed application's version, not this gem's.
184
+ # The posthog-rails name/version travel with each record via the
185
+ # instrumentation scope (see LoggerProvider#logger above).
186
+ {
187
+ 'service.name' => service_name,
188
+ 'deployment.environment' => ::Rails.env.to_s
189
+ }
190
+ end
191
+
192
+ def service_name
193
+ app = ::Rails.application
194
+ return 'unknown_service' unless app
195
+
196
+ name = app.class.respond_to?(:module_parent_name) ? app.class.module_parent_name : nil
197
+ name && !name.empty? ? name.to_s : 'unknown_service'
198
+ rescue StandardError
199
+ 'unknown_service'
200
+ end
201
+
202
+ def logs_endpoint(host)
203
+ base = (host || 'https://us.i.posthog.com').to_s.sub(%r{/+\z}, '')
204
+ "#{base}/i/v1/logs"
205
+ end
206
+
207
+ def resolve_level(level)
208
+ return nil if level.nil?
209
+ return level if level.is_a?(Integer)
210
+
211
+ # const_get can return non-severity values without raising: Logger's
212
+ # own VERSION/SEV_LABEL, and (via the default inherited lookup) any
213
+ # top-level all-caps constant like ENV/ARGV/STDOUT. The Integer guard
214
+ # rejects them so they don't later blow up the severity comparison.
215
+ # (inherit must stay true — the severity constants live in the
216
+ # included Logger::Severity module.)
217
+ result = ::Logger.const_get(level.to_s.upcase)
218
+ raise NameError, 'not a severity integer' unless result.is_a?(Integer)
219
+
220
+ result
221
+ rescue NameError
222
+ warn_once(
223
+ "Invalid logs_level #{level.inspect}; expected one of :debug, :info, :warn, " \
224
+ ':error, :fatal, :unknown (or an Integer). Falling back to the Rails logger level.'
225
+ )
226
+ nil
227
+ end
228
+
229
+ def rails_logger_level
230
+ ::Rails.logger&.level
231
+ rescue StandardError
232
+ nil
233
+ end
234
+
235
+ def normalize(value)
236
+ return nil unless value.is_a?(String)
237
+
238
+ stripped = value.strip
239
+ stripped.empty? ? nil : stripped
240
+ end
241
+
242
+ def warn_once(message)
243
+ return if @warned
244
+
245
+ @warned = true
246
+ logger.warn(message)
247
+ end
248
+
249
+ def logger
250
+ PostHog::Logging.logger
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module PostHog
6
+ module Rails
7
+ module Logs
8
+ # Maps Ruby `Logger` severities to OpenTelemetry log severity numbers and text.
9
+ #
10
+ # OpenTelemetry defines severity ranges (DEBUG=5-8, INFO=9-12, WARN=13-16,
11
+ # ERROR=17-20, FATAL=21-24); we map each Ruby level to the base of its range.
12
+ #
13
+ # @api private
14
+ module Severity
15
+ module_function
16
+
17
+ # @param severity [Integer, nil] A Ruby `Logger` severity constant.
18
+ # @return [Symbol] The severity name (:debug, :info, :warn, :error, :fatal).
19
+ def name_for(severity)
20
+ NAMES.fetch(severity, :info)
21
+ end
22
+
23
+ # @param name [Symbol, String, nil] A severity name such as :warn.
24
+ # @return [Array(Integer, String)] OpenTelemetry severity number and text;
25
+ # unrecognized names fall back to INFO.
26
+ def for_name(name)
27
+ OTEL.fetch(name.to_s.downcase.to_sym, OTEL[:info])
28
+ end
29
+
30
+ NAMES = {
31
+ ::Logger::DEBUG => :debug,
32
+ ::Logger::INFO => :info,
33
+ ::Logger::WARN => :warn,
34
+ ::Logger::ERROR => :error,
35
+ ::Logger::FATAL => :fatal,
36
+ ::Logger::UNKNOWN => :info
37
+ }.freeze
38
+
39
+ OTEL = {
40
+ debug: [5, 'DEBUG'],
41
+ info: [9, 'INFO'],
42
+ warn: [13, 'WARN'],
43
+ error: [17, 'ERROR'],
44
+ fatal: [21, 'FATAL']
45
+ }.freeze
46
+ end
47
+ end
48
+ end
49
+ end
@@ -34,6 +34,10 @@ module PostHog
34
34
  options = config.to_client_options
35
35
  end
36
36
 
37
+ # Let the PostHog Logs pipeline reuse the same api_key/host without
38
+ # the core client exposing public readers.
39
+ PostHog::Rails::Logs::Setup.remember_client_options(options) if defined?(PostHog::Rails::Logs::Setup)
40
+
37
41
  # Create the PostHog client
38
42
  @client = PostHog::Client.new(options)
39
43
  end
@@ -118,12 +122,20 @@ module PostHog
118
122
  register_error_subscriber if rails_version_above_7?
119
123
  end
120
124
 
125
+ # Opt-in: forward logs to PostHog Logs over OTLP
126
+ config.after_initialize do
127
+ install_posthog_logs if PostHog::Rails.config&.logs_enabled
128
+ end
129
+
121
130
  # Ensure PostHog shuts down gracefully (register only once)
122
131
  config.after_initialize do
123
132
  next if @posthog_at_exit_registered
124
133
 
125
134
  @posthog_at_exit_registered = true
126
- at_exit { PostHog.client&.shutdown if PostHog.initialized? }
135
+ at_exit do
136
+ PostHog::Rails::Logs::Setup.shutdown
137
+ PostHog.client&.shutdown if PostHog.initialized?
138
+ end
127
139
  end
128
140
 
129
141
  # @api private
@@ -144,6 +156,54 @@ module PostHog
144
156
  app.config.middleware.insert_before(target, middleware)
145
157
  end
146
158
 
159
+ # Build the PostHog Logs pipeline and broadcast Rails.logger into it.
160
+ #
161
+ # @api private
162
+ # @return [void]
163
+ def self.install_posthog_logs
164
+ unless PostHog.initialized?
165
+ # logs_enabled is an explicit opt-in, so leave a breadcrumb instead
166
+ # of silently skipping when PostHog.init never ran.
167
+ PostHog::Logging.logger.warn(
168
+ 'PostHog Logs is enabled but PostHog.init has not been called; ' \
169
+ 'skipping log forwarding. Call PostHog.init in your initializer.'
170
+ )
171
+ return
172
+ end
173
+
174
+ # Mirror the core client: when it is disabled (missing/blank api_key)
175
+ # every capture no-ops, so log forwarding should stay off too. The
176
+ # client already logs its own missing-api_key error, so skip quietly.
177
+ return unless PostHog.client.enabled?
178
+
179
+ appender = PostHog::Rails::Logs::Setup.install
180
+ return if appender.nil?
181
+
182
+ broadcast_rails_logger(appender) if PostHog::Rails.config&.logs_forward_rails_logger
183
+ rescue StandardError => e
184
+ PostHog::Logging.logger.warn("Failed to set up PostHog Logs: #{e.message}")
185
+ end
186
+
187
+ # Attach the appender to Rails.logger, supporting both the Rails 7.1+
188
+ # BroadcastLogger and the older ActiveSupport::Logger.broadcast mechanism.
189
+ #
190
+ # @api private
191
+ # @return [void]
192
+ def self.broadcast_rails_logger(appender)
193
+ logger = ::Rails.logger
194
+ return unless logger
195
+
196
+ if logger.respond_to?(:broadcast_to)
197
+ logger.broadcast_to(appender)
198
+ elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
199
+ logger.extend(ActiveSupport::Logger.broadcast(appender))
200
+ else
201
+ PostHog::Logging.logger.warn(
202
+ 'PostHog Logs could not broadcast Rails.logger; no compatible broadcast mechanism found.'
203
+ )
204
+ end
205
+ end
206
+
147
207
  # @api private
148
208
  # @return [void]
149
209
  def self.register_error_subscriber
data/lib/posthog/rails.rb CHANGED
@@ -8,6 +8,10 @@ require 'posthog/rails/capture_exceptions'
8
8
  require 'posthog/rails/rescued_exception_interceptor'
9
9
  require 'posthog/rails/active_job'
10
10
  require 'posthog/rails/error_subscriber'
11
+ require 'posthog/rails/logs/severity'
12
+ require 'posthog/rails/logs/rate_limiter'
13
+ require 'posthog/rails/logs/appender'
14
+ require 'posthog/rails/logs/setup'
11
15
  require 'posthog/rails/railtie'
12
16
 
13
17
  module PostHog
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.11.1
4
+ version: 3.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - PostHog
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '3.11'
32
+ version: '3.12'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '3.11'
39
+ version: '3.12'
40
40
  description: Automatic exception tracking and instrumentation for Ruby on Rails applications
41
41
  using PostHog
42
42
  email: engineering@posthog.com
@@ -52,6 +52,10 @@ files:
52
52
  - lib/posthog/rails/capture_exceptions.rb
53
53
  - lib/posthog/rails/configuration.rb
54
54
  - lib/posthog/rails/error_subscriber.rb
55
+ - lib/posthog/rails/logs/appender.rb
56
+ - lib/posthog/rails/logs/rate_limiter.rb
57
+ - lib/posthog/rails/logs/setup.rb
58
+ - lib/posthog/rails/logs/severity.rb
55
59
  - lib/posthog/rails/parameter_filter.rb
56
60
  - lib/posthog/rails/railtie.rb
57
61
  - lib/posthog/rails/request_context.rb