From 883b49697fdb16a9401ba04ce2ce9332c5288036 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 4 Jun 2026 14:28:53 -0400 Subject: [PATCH 01/30] feat(rails): forward Rails.logger to PostHog Logs via OpenTelemetry Add an opt-in integration that broadcasts Rails.logger output to PostHog Logs over OTLP, automatically correlated with the request's distinct_id and session_id captured by the existing request-context middleware. The OpenTelemetry gems (opentelemetry-sdk, opentelemetry-logs-sdk, opentelemetry-exporter-otlp-logs) are optional/soft dependencies loaded lazily at runtime, so the feature no-ops with a single warning when they are absent (and on Ruby < 3.3 where the logs SDK is unsupported). The logs token and host are derived from the configured PostHog client (config.api_key / config.host) with ENV fallbacks, so logs always target the same project as analytics. Exposes api_key/host readers on the core client to support this. Disabled by default; enable via PostHog::Rails.config.logs_enabled. --- lib/posthog/client.rb | 8 + posthog-rails/README.md | 26 +++ posthog-rails/examples/posthog.rb | 26 +++ .../generators/posthog/install_generator.rb | 8 + .../generators/posthog/templates/posthog.rb | 26 +++ posthog-rails/lib/posthog/rails.rb | 3 + .../lib/posthog/rails/configuration.rb | 18 ++ .../lib/posthog/rails/logs/appender.rb | 105 +++++++++ posthog-rails/lib/posthog/rails/logs/setup.rb | 199 ++++++++++++++++++ .../lib/posthog/rails/logs/severity.rb | 36 ++++ posthog-rails/lib/posthog/rails/railtie.rb | 45 +++- spec/posthog/rails/configuration_spec.rb | 9 + spec/posthog/rails/logs/appender_spec.rb | 111 ++++++++++ spec/posthog/rails/logs/setup_spec.rb | 129 ++++++++++++ spec/posthog/rails/railtie_spec.rb | 96 +++++++++ 15 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 posthog-rails/lib/posthog/rails/logs/appender.rb create mode 100644 posthog-rails/lib/posthog/rails/logs/setup.rb create mode 100644 posthog-rails/lib/posthog/rails/logs/severity.rb create mode 100644 spec/posthog/rails/logs/appender_spec.rb create mode 100644 spec/posthog/rails/logs/setup_spec.rb diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 219a0ae..ed5459e 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -51,6 +51,13 @@ def _decrement_instance_count(api_key) end end + # @return [String, nil] The project API key this client was initialized with + # (after whitespace trimming). Nil when the client is disabled. + attr_reader :api_key + + # @return [String] The fully qualified PostHog host this client was initialized with. + attr_reader :host + # @param opts [Hash] Client configuration. # @option opts [String, nil] :api_key Your project's API key. Missing or blank values disable the client. # @option opts [String, nil] :personal_api_key Your personal API key. Required for local feature flag evaluation. @@ -85,6 +92,7 @@ def initialize(opts = {}) @queue = Queue.new @api_key = opts[:api_key] + @host = opts[:host] @disabled = @api_key.nil? || @api_key.empty? @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE @worker_mutex = Mutex.new diff --git a/posthog-rails/README.md b/posthog-rails/README.md index bbfe5dd..7c74a9b 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -7,3 +7,29 @@ For installation, configuration, usage, and troubleshooting, see the official do https://posthog.com/docs/libraries/ruby-on-rails Keeping usage docs in one place avoids stale examples in this repository. + +## PostHog Logs (optional) + +`posthog-rails` can forward `Rails.logger` output to [PostHog Logs](https://posthog.com/docs/logs) +over OpenTelemetry (OTLP), automatically correlated with the request's PostHog +distinct ID and session ID. + +This is opt-in and relies on the standard OpenTelemetry gems (Ruby 3.3+), which +are not bundled. Add them to your `Gemfile`: + +```ruby +gem 'opentelemetry-sdk' +gem 'opentelemetry-logs-sdk' +gem 'opentelemetry-exporter-otlp-logs' +``` + +Then enable it in `config/initializers/posthog.rb`: + +```ruby +PostHog::Rails.configure do |config| + config.logs_enabled = true +end +``` + +When the OpenTelemetry gems are absent, the feature logs a single warning and +no-ops, so it is safe to enable conditionally. diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index ffd8318..bda9882 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -43,6 +43,32 @@ # # 'MyCustom404Error', # # 'MyCustomValidationError' # ] + + # -------------------------------------------------------------------------- + # POSTHOG LOGS (OpenTelemetry) - opt-in + # -------------------------------------------------------------------------- + # Forward Rails.logger output to PostHog Logs over OTLP, automatically + # correlated with the request's distinct_id and session_id. + # + # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile: + # gem 'opentelemetry-sdk' + # gem 'opentelemetry-logs-sdk' + # gem 'opentelemetry-exporter-otlp-logs' + # + # Enable log forwarding (default: false) + # config.logs_enabled = true + + # Broadcast Rails.logger into PostHog Logs (default: true when logs enabled) + # config.forward_rails_logger = true + + # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) + # config.logs_level = :info + + # Logs reuse the same project token (api_key) and host configured below, so + # there is nothing extra to set. Logs are sent to /i/v1/logs. + + # Extra OpenTelemetry resource attributes merged with service metadata + # config.logs_resource_attributes = { 'service.namespace' => 'my-team' } end # You can also configure Rails options directly: diff --git a/posthog-rails/lib/generators/posthog/install_generator.rb b/posthog-rails/lib/generators/posthog/install_generator.rb index 5c3a04a..6947d06 100644 --- a/posthog-rails/lib/generators/posthog/install_generator.rb +++ b/posthog-rails/lib/generators/posthog/install_generator.rb @@ -23,6 +23,14 @@ def show_readme say ' - POSTHOG_API_KEY (required)' say ' - POSTHOG_PERSONAL_API_KEY (optional, for feature flags)' say '' + say 'Optional: forward Rails.logger to PostHog Logs', :yellow + say ' - Add to your Gemfile (requires Ruby 3.3+):' + say " gem 'opentelemetry-sdk'" + say " gem 'opentelemetry-logs-sdk'" + say " gem 'opentelemetry-exporter-otlp-logs'" + say ' - Set config.logs_enabled = true in the initializer' + say ' - Docs: https://posthog.com/docs/logs' + say '' say 'For more information, see: https://posthog.com/docs/libraries/ruby' say '' end diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index d5ac82d..332882a 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -43,6 +43,32 @@ # # 'MyCustom404Error', # # 'MyCustomValidationError' # ] + + # -------------------------------------------------------------------------- + # POSTHOG LOGS (OpenTelemetry) - opt-in + # -------------------------------------------------------------------------- + # Forward Rails.logger output to PostHog Logs over OTLP, automatically + # correlated with the request's distinct_id and session_id. + # + # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile: + # gem 'opentelemetry-sdk' + # gem 'opentelemetry-logs-sdk' + # gem 'opentelemetry-exporter-otlp-logs' + # + # Enable log forwarding (default: false) + # config.logs_enabled = true + + # Broadcast Rails.logger into PostHog Logs (default: true when logs enabled) + # config.forward_rails_logger = true + + # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) + # config.logs_level = :info + + # Logs reuse the same project token (api_key) and host configured below, so + # there is nothing extra to set. Logs are sent to /i/v1/logs. + + # Extra OpenTelemetry resource attributes merged with service metadata + # config.logs_resource_attributes = { 'service.namespace' => 'my-team' } end # You can also configure Rails options directly: diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 24c1727..e4543b3 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -8,6 +8,9 @@ require 'posthog/rails/rescued_exception_interceptor' require 'posthog/rails/active_job' require 'posthog/rails/error_subscriber' +require 'posthog/rails/logs/severity' +require 'posthog/rails/logs/appender' +require 'posthog/rails/logs/setup' require 'posthog/rails/railtie' module PostHog diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 9314f43..22ec2a7 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -33,6 +33,20 @@ class Configuration # posthog_distinct_id, distinct_id, id, pk, uuid in order. attr_accessor :user_id_method + # @return [Boolean] Master switch for forwarding logs to PostHog Logs over OTLP. Defaults to false. + attr_accessor :logs_enabled + + # @return [Boolean] Whether to broadcast Rails.logger output into the PostHog Logs sink. Defaults to true + # (only takes effect when {#logs_enabled} is true). + attr_accessor :forward_rails_logger + + # @return [Integer, Symbol, nil] Minimum severity to forward to PostHog Logs. When nil, inherits the + # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info). + attr_accessor :logs_level + + # @return [Hash] Extra OpenTelemetry resource attributes merged with auto-detected service metadata. + attr_accessor :logs_resource_attributes + # @return [PostHog::Rails::Configuration] def initialize @auto_capture_exceptions = false @@ -43,6 +57,10 @@ def initialize @capture_user_context = true @current_user_method = :current_user @user_id_method = nil + @logs_enabled = false + @forward_rails_logger = true + @logs_level = nil + @logs_resource_attributes = {} end # Default exceptions that Rails apps typically don't want to track. diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb new file mode 100644 index 0000000..a626529 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'logger' +require 'time' +require 'posthog/internal/context' +require 'posthog/rails/logs/severity' + +module PostHog + module Rails + module Logs + # A `Logger`-compatible sink that forwards each log record to an + # OpenTelemetry logger as an OTLP log record. + # + # It is designed to be broadcast alongside the app's existing + # `Rails.logger` so that ordinary `Rails.logger.info(...)` calls flow to + # PostHog Logs in addition to the normal output. Each record is stamped + # with the request-scoped PostHog identity captured by + # {PostHog::Rails::RequestContext}. + # + # @api private + class Appender < ::Logger + SELF_LOG_PREFIX = '[posthog-ruby]' + SELF_LOG_PROGNAME = 'PostHog' + REQUEST_ATTRIBUTE_KEYS = %w[$current_url $request_method $request_path].freeze + + # @param otel_logger [#on_emit] An OpenTelemetry logger. + # @param level [Integer, nil] Minimum severity to forward. + def initialize(otel_logger, level: nil) + super(nil) + @otel_logger = otel_logger + self.level = level unless level.nil? + end + + # Mirrors `Logger#add` message/progname resolution, then emits to OTel + # instead of writing to a log device. + # + # @return [Boolean] Always true so it composes with broadcast loggers. + def add(severity, message = nil, progname = nil) + severity ||= ::Logger::UNKNOWN + return true if severity < level + + if message.nil? + if block_given? + message = yield + else + message = progname + progname = nil + end + end + + return true if message.nil? + return true if self_log?(message, progname) + + emit(severity, message, progname) + true + rescue StandardError + # Never let log forwarding break the calling code path. + true + end + + private + + def emit(severity, message, progname) + severity_number, severity_text = Severity.for(severity) + @otel_logger.on_emit( + timestamp: Time.now, + severity_number: severity_number, + severity_text: severity_text, + body: body_for(message), + attributes: attributes_for(progname) + ) + end + + def body_for(message) + message.is_a?(String) ? message : message.inspect + end + + def attributes_for(progname) + attributes = {} + attributes['logger.progname'] = progname.to_s if progname + + context = Internal::Context.current + return attributes unless context + + attributes['posthogDistinctId'] = context.distinct_id if context.distinct_id + attributes['sessionId'] = context.session_id if context.session_id + + properties = context.properties || {} + REQUEST_ATTRIBUTE_KEYS.each do |key| + value = properties[key] || properties[key.to_sym] + attributes[key] = value if value + end + + attributes + end + + def self_log?(message, progname) + return true if progname.to_s == SELF_LOG_PROGNAME + + message.is_a?(String) && message.include?(SELF_LOG_PREFIX) + end + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb new file mode 100644 index 0000000..0c50839 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'logger' +require 'posthog/logging' +require 'posthog/rails/logs/appender' + +module PostHog + module Rails + module Logs + # Bootstraps the OpenTelemetry logs pipeline that ships PostHog Logs. + # + # The OpenTelemetry gems are optional/soft dependencies. They are required + # lazily here so that apps which do not enable logs (or run on a Ruby + # version the logs SDK does not support) are unaffected. + # + # @api private + module Setup + class << self + # @return [OpenTelemetry::SDK::Logs::LoggerProvider, nil] + attr_reader :provider + + # @return [PostHog::Rails::Logs::Appender, nil] + attr_reader :appender + + # Build the logs pipeline and return the broadcastable appender. + # + # Idempotent: subsequent calls return the previously built appender + # (or nil if setup was skipped). + # + # @return [PostHog::Rails::Logs::Appender, nil] + def install! + return @appender if @installed + + @installed = true + return nil unless require_otel_gems + + config = PostHog::Rails.config + token = resolve_token + if token.nil? + warn_once( + 'PostHog Logs enabled but no project token could be resolved ' \ + '(set config.api_key or POSTHOG_API_KEY); skipping.' + ) + return nil + end + + @provider = build_provider(config, token) + otel_logger = @provider.logger(name: 'posthog-rails', version: PostHog::VERSION) + level = resolve_level(config.logs_level) || rails_logger_level + @appender = Appender.new(otel_logger, level: level) + rescue StandardError => e + warn_once("Failed to initialize PostHog Logs: #{e.message}") + nil + end + + # Flush any buffered log records. + # + # @return [void] + def force_flush + @provider&.force_flush + rescue StandardError => e + logger.warn("Error flushing PostHog Logs: #{e.message}") + end + + # Shut the pipeline down, flushing buffered records. + # + # @return [void] + def shutdown! + @provider&.shutdown + rescue StandardError => e + logger.warn("Error shutting down PostHog Logs: #{e.message}") + end + + # Resets memoized state. Intended for tests. + # + # @return [void] + def reset! + @installed = false + @provider = nil + @appender = nil + @warned = false + end + + private + + # The logs token is the same project token the core client uses + # (i.e. config.api_key), falling back to ENV['POSTHOG_API_KEY']. + def resolve_token + normalize(client_attribute(:api_key)) || normalize(ENV.fetch('POSTHOG_API_KEY', nil)) + end + + # The logs host follows the core client's configured host, falling back + # to ENV['POSTHOG_HOST'] and finally the US cloud endpoint. + def resolve_host + normalize(client_attribute(:host)) || + normalize(ENV.fetch('POSTHOG_HOST', nil)) || + 'https://us.i.posthog.com' + end + + def client_attribute(name) + return nil unless PostHog.respond_to?(:client) + + client = PostHog.client + client.respond_to?(name) ? client.public_send(name) : nil + rescue StandardError + nil + end + + def require_otel_gems + require 'opentelemetry-sdk' + require 'opentelemetry-logs-sdk' + require 'opentelemetry/exporter/otlp_logs' + true + rescue LoadError => e + warn_once( + "PostHog Logs enabled but the OpenTelemetry gems are missing (#{e.message}). " \ + "Add 'opentelemetry-sdk', 'opentelemetry-logs-sdk', and " \ + "'opentelemetry-exporter-otlp-logs' to your Gemfile to enable log forwarding." + ) + false + end + + def build_provider(config, token) + resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attributes(config)) + provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource) + exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( + endpoint: logs_endpoint(resolve_host), + headers: { 'Authorization' => "Bearer #{token}" } + ) + processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter) + provider.add_log_record_processor(processor) + provider + end + + def resource_attributes(config) + attrs = { + 'service.name' => service_name, + 'service.version' => PostHog::VERSION, + 'deployment.environment' => ::Rails.env.to_s + } + attrs.merge(stringify_keys(config.logs_resource_attributes || {})) + end + + def service_name + app = ::Rails.application + return 'rails' unless app + + name = app.class.respond_to?(:module_parent_name) ? app.class.module_parent_name : nil + name && !name.empty? ? name.to_s : 'rails' + rescue StandardError + 'rails' + end + + def logs_endpoint(host) + base = (host || 'https://us.i.posthog.com').to_s.sub(%r{/+\z}, '') + "#{base}/i/v1/logs" + end + + def resolve_level(level) + return nil if level.nil? + return level if level.is_a?(Integer) + + ::Logger.const_get(level.to_s.upcase) + rescue NameError + nil + end + + def rails_logger_level + ::Rails.logger&.level + rescue StandardError + nil + end + + def normalize(value) + return nil unless value.is_a?(String) + + stripped = value.strip + stripped.empty? ? nil : stripped + end + + def stringify_keys(hash) + hash.transform_keys(&:to_s) + end + + def warn_once(message) + return if @warned + + @warned = true + logger.warn(message) + end + + def logger + PostHog::Logging.logger + end + end + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/logs/severity.rb b/posthog-rails/lib/posthog/rails/logs/severity.rb new file mode 100644 index 0000000..538efcc --- /dev/null +++ b/posthog-rails/lib/posthog/rails/logs/severity.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'logger' + +module PostHog + module Rails + module Logs + # Maps Ruby `Logger` severities to OpenTelemetry log severity numbers and text. + # + # OpenTelemetry defines severity ranges (DEBUG=5-8, INFO=9-12, WARN=13-16, + # ERROR=17-20, FATAL=21-24); we map each Ruby level to the base of its range. + # + # @api private + module Severity + module_function + + # @param severity [Integer, nil] A Ruby `Logger` severity constant. + # @return [Array(Integer, String)] OpenTelemetry severity number and text. + def for(severity) + MAPPING.fetch(severity, DEFAULT) + end + + MAPPING = { + ::Logger::DEBUG => [5, 'DEBUG'], + ::Logger::INFO => [9, 'INFO'], + ::Logger::WARN => [13, 'WARN'], + ::Logger::ERROR => [17, 'ERROR'], + ::Logger::FATAL => [21, 'FATAL'], + ::Logger::UNKNOWN => [9, 'INFO'] + }.freeze + + DEFAULT = [9, 'INFO'].freeze + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index e1cf6e0..465e535 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -118,12 +118,20 @@ def ensure_initialized! register_error_subscriber if rails_version_above_7? end + # Opt-in: forward logs to PostHog Logs over OTLP + config.after_initialize do + install_posthog_logs if PostHog::Rails.config&.logs_enabled + end + # Ensure PostHog shuts down gracefully (register only once) config.after_initialize do next if @posthog_at_exit_registered @posthog_at_exit_registered = true - at_exit { PostHog.client&.shutdown if PostHog.initialized? } + at_exit do + PostHog::Rails::Logs::Setup.shutdown! + PostHog.client&.shutdown if PostHog.initialized? + end end # @api private @@ -144,6 +152,41 @@ def insert_middleware_before(app, target, middleware) app.config.middleware.insert_before(target, middleware) end + # Build the PostHog Logs pipeline and broadcast Rails.logger into it. + # + # @api private + # @return [void] + def self.install_posthog_logs + return unless PostHog.initialized? + + appender = PostHog::Rails::Logs::Setup.install! + return if appender.nil? + + broadcast_rails_logger(appender) if PostHog::Rails.config&.forward_rails_logger + rescue StandardError => e + PostHog::Logging.logger.warn("Failed to set up PostHog Logs: #{e.message}") + end + + # Attach the appender to Rails.logger, supporting both the Rails 7.1+ + # BroadcastLogger and the older ActiveSupport::Logger.broadcast mechanism. + # + # @api private + # @return [void] + def self.broadcast_rails_logger(appender) + logger = ::Rails.logger + return unless logger + + if logger.respond_to?(:broadcast_to) + logger.broadcast_to(appender) + elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast) + logger.extend(ActiveSupport::Logger.broadcast(appender)) + else + PostHog::Logging.logger.warn( + 'PostHog Logs could not broadcast Rails.logger; no compatible broadcast mechanism found.' + ) + end + end + # @api private # @return [void] def self.register_error_subscriber diff --git a/spec/posthog/rails/configuration_spec.rb b/spec/posthog/rails/configuration_spec.rb index b46a110..d5c423f 100644 --- a/spec/posthog/rails/configuration_spec.rb +++ b/spec/posthog/rails/configuration_spec.rb @@ -39,4 +39,13 @@ expect(config.should_capture_exception?(ActionController::RoutingError.new('x'))).to be false end end + + describe 'PostHog Logs defaults' do + it 'defaults logs to disabled with forwarding ready' do + expect(config.logs_enabled).to be false + expect(config.forward_rails_logger).to be true + expect(config.logs_level).to be_nil + expect(config.logs_resource_attributes).to eq({}) + end + end end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb new file mode 100644 index 0000000..be961bc --- /dev/null +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +$LOAD_PATH.unshift File.expand_path('../../../../posthog-rails/lib', __dir__) + +require 'posthog/rails/logs/appender' + +RSpec.describe PostHog::Rails::Logs::Appender do + let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } + + # Records every on_emit call so we can assert the emitted payload. + let(:otel_logger) do + Class.new do + attr_reader :emitted + + def initialize + @emitted = [] + end + + def on_emit(**kwargs) + @emitted << kwargs + end + end.new + end + + subject(:appender) { described_class.new(otel_logger, level: Logger::INFO) } + + describe '#add' do + it 'emits a record with body and mapped severity' do + appender.info('hello world') + + expect(otel_logger.emitted.size).to eq(1) + record = otel_logger.emitted.first + expect(record[:body]).to eq('hello world') + expect(record[:severity_number]).to eq(9) + expect(record[:severity_text]).to eq('INFO') + end + + it 'maps error severity' do + appender.error('boom') + + record = otel_logger.emitted.first + expect(record[:severity_number]).to eq(17) + expect(record[:severity_text]).to eq('ERROR') + end + + it 'drops messages below the configured level' do + appender.debug('too quiet') + + expect(otel_logger.emitted).to be_empty + end + + it 'resolves block-form messages' do + appender.info { 'lazy message' } + + expect(otel_logger.emitted.first[:body]).to eq('lazy message') + end + + it 'inspects non-string messages' do + appender.info(%w[a b]) + + expect(otel_logger.emitted.first[:body]).to eq('["a", "b"]') + end + + it 'suppresses self-logs carrying the posthog-ruby prefix' do + appender.info('[posthog-ruby] internal diagnostic') + + expect(otel_logger.emitted).to be_empty + end + + it 'suppresses logs emitted under the PostHog progname' do + appender.info('PostHog') { 'internal diagnostic' } + + expect(otel_logger.emitted).to be_empty + end + + it 'never raises even if the otel logger blows up' do + allow(otel_logger).to receive(:on_emit).and_raise(StandardError, 'export failed') + + expect { appender.info('hello') }.not_to raise_error + expect(appender.info('hello')).to be(true) + end + end + + describe 'context correlation' do + it 'stamps the request distinct_id, session_id, and request metadata' do + context_class.with_context( + distinct_id: 'user-42', + session_id: 'session-99', + properties: { '$current_url' => 'https://example.com/widgets', '$request_method' => 'GET' } + ) do + appender.info('within request') + end + + attributes = otel_logger.emitted.first[:attributes] + expect(attributes['posthogDistinctId']).to eq('user-42') + expect(attributes['sessionId']).to eq('session-99') + expect(attributes['$current_url']).to eq('https://example.com/widgets') + expect(attributes['$request_method']).to eq('GET') + end + + it 'omits correlation attributes when there is no active context' do + appender.info('no context') + + attributes = otel_logger.emitted.first[:attributes] + expect(attributes).not_to have_key('posthogDistinctId') + expect(attributes).not_to have_key('sessionId') + end + end +end diff --git a/spec/posthog/rails/logs/setup_spec.rb b/spec/posthog/rails/logs/setup_spec.rb new file mode 100644 index 0000000..d678d83 --- /dev/null +++ b/spec/posthog/rails/logs/setup_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rails' + +$LOAD_PATH.unshift File.expand_path('../../../../posthog-rails/lib', __dir__) + +require 'posthog/rails' + +RSpec.describe PostHog::Rails::Logs::Setup do + around do |example| + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + described_class.reset! + example.run + ensure + described_class.reset! + PostHog::Rails.config = previous_config + end + + describe '.install!' do + context 'when the OpenTelemetry gems are missing' do + before do + allow(described_class).to receive(:require).and_wrap_original do |original, name, *rest| + raise LoadError, "cannot load such file -- #{name}" if name.to_s.start_with?('opentelemetry') + + original.call(name, *rest) + end + end + + it 'no-ops and warns exactly once' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + + expect(described_class.install!).to be_nil + described_class.install! # idempotent; should not warn again + + expect(logger).to have_received(:warn).once + end + end + + context 'when no token can be resolved' do + before do + allow(described_class).to receive(:require_otel_gems).and_return(true) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('POSTHOG_API_KEY', nil).and_return(nil) + end + + it 'no-ops and warns about the missing token' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + + expect(described_class.install!).to be_nil + expect(logger).to have_received(:warn).once + end + end + + context 'when the OpenTelemetry gems are available' do + let(:exporter_args) { {} } + let(:otel_logger) { double('otel_logger') } + let(:provider) { double('provider', add_log_record_processor: nil, logger: otel_logger) } + + before do + allow(described_class).to receive(:require_otel_gems).and_return(true) + + resource_class = Class.new + resource_class.define_singleton_method(:create) { |attrs| attrs } + + provider_double = provider + provider_class = Class.new + provider_class.define_singleton_method(:new) { |**| provider_double } + + captured = exporter_args + exporter_class = Class.new + exporter_class.define_singleton_method(:new) do |**kwargs| + captured.merge!(kwargs) + Object.new + end + + processor_class = Class.new + processor_class.define_singleton_method(:new) { |_exporter| Object.new } + + stub_const('OpenTelemetry::SDK::Resources::Resource', resource_class) + stub_const('OpenTelemetry::SDK::Logs::LoggerProvider', provider_class) + stub_const('OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor', processor_class) + stub_const('OpenTelemetry::Exporter::OTLP::Logs::LogsExporter', exporter_class) + end + + it 'derives the OTLP endpoint and bearer token from the configured client' do + allow(PostHog).to receive(:client) + .and_return(double('client', api_key: 'phc_token', host: 'https://us.i.posthog.com')) + + appender = described_class.install! + + expect(appender).to be_a(PostHog::Rails::Logs::Appender) + expect(exporter_args[:endpoint]).to eq('https://us.i.posthog.com/i/v1/logs') + expect(exporter_args[:headers]).to eq('Authorization' => 'Bearer phc_token') + end + + it 'follows the client host, stripping a trailing slash' do + allow(PostHog).to receive(:client) + .and_return(double('client', api_key: 'phc_token', host: 'https://eu.i.posthog.com/')) + + described_class.install! + + expect(exporter_args[:endpoint]).to eq('https://eu.i.posthog.com/i/v1/logs') + end + + it 'falls back to ENV for token and host when no client is configured' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('POSTHOG_API_KEY', nil).and_return('phc_env') + allow(ENV).to receive(:fetch).with('POSTHOG_HOST', nil).and_return('https://eu.i.posthog.com') + + described_class.install! + + expect(exporter_args[:headers]).to eq('Authorization' => 'Bearer phc_env') + expect(exporter_args[:endpoint]).to eq('https://eu.i.posthog.com/i/v1/logs') + end + + it 'is idempotent and returns the same appender' do + allow(PostHog).to receive(:client) + .and_return(double('client', api_key: 'phc_token', host: 'https://us.i.posthog.com')) + + first = described_class.install! + expect(described_class.install!).to be(first) + end + end + end +end diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb index 0c5677a..aaa3ac9 100644 --- a/spec/posthog/rails/railtie_spec.rb +++ b/spec/posthog/rails/railtie_spec.rb @@ -15,6 +15,10 @@ require 'posthog/rails/configuration' require 'posthog/rails/railtie' +# The PostHog Logs wiring tests below exercise the full Rails integration +# (config singleton + Logs::Setup), so load it in full. +require 'posthog/rails' + RSpec.describe PostHog::Rails::Railtie do describe 'posthog.set_configs initializer' do before do @@ -84,4 +88,96 @@ end.not_to raise_error end end + + describe 'PostHog Logs wiring' do + around do |example| + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + example.run + ensure + PostHog::Rails.config = previous_config + end + + before do + initializer = PostHog::Rails::Railtie.initializers.find { |i| i.name == 'posthog.set_configs' } + PostHog::Rails::Railtie.instance.instance_exec(double('app'), &initializer.block) + PostHog::Logging.logger = Logger.new(File::NULL) + PostHog.client = nil + end + + after { PostHog.client = nil } + + describe '.install_posthog_logs' do + it 'no-ops when PostHog is not initialized' do + allow(PostHog::Rails::Logs::Setup).to receive(:install!) + + PostHog::Rails::Railtie.install_posthog_logs + + expect(PostHog::Rails::Logs::Setup).not_to have_received(:install!) + end + + it 'broadcasts Rails.logger when an appender is built' do + PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true) + appender = instance_double(PostHog::Rails::Logs::Appender) + allow(PostHog::Rails::Logs::Setup).to receive(:install!).and_return(appender) + allow(PostHog::Rails::Railtie).to receive(:broadcast_rails_logger) + + PostHog::Rails::Railtie.install_posthog_logs + + expect(PostHog::Rails::Railtie).to have_received(:broadcast_rails_logger).with(appender) + end + + it 'does not broadcast when forward_rails_logger is disabled' do + PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true) + PostHog::Rails.config.forward_rails_logger = false + allow(PostHog::Rails::Logs::Setup).to receive(:install!) + .and_return(instance_double(PostHog::Rails::Logs::Appender)) + allow(PostHog::Rails::Railtie).to receive(:broadcast_rails_logger) + + PostHog::Rails::Railtie.install_posthog_logs + + expect(PostHog::Rails::Railtie).not_to have_received(:broadcast_rails_logger) + end + + it 'does not broadcast when setup returns nil' do + PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true) + allow(PostHog::Rails::Logs::Setup).to receive(:install!).and_return(nil) + allow(PostHog::Rails::Railtie).to receive(:broadcast_rails_logger) + + PostHog::Rails::Railtie.install_posthog_logs + + expect(PostHog::Rails::Railtie).not_to have_received(:broadcast_rails_logger) + end + end + + describe '.broadcast_rails_logger' do + let(:appender) { instance_double(PostHog::Rails::Logs::Appender) } + + it 'uses broadcast_to on Rails 7.1+ broadcast loggers' do + logger = double('logger') + allow(logger).to receive(:respond_to?).with(:broadcast_to).and_return(true) + allow(logger).to receive(:broadcast_to) + allow(Rails).to receive(:logger).and_return(logger) + + PostHog::Rails::Railtie.broadcast_rails_logger(appender) + + expect(logger).to have_received(:broadcast_to).with(appender) + end + + it 'falls back to ActiveSupport::Logger.broadcast on older Rails' do + logger = double('logger') + allow(logger).to receive(:respond_to?).with(:broadcast_to).and_return(false) + allow(logger).to receive(:extend) + allow(Rails).to receive(:logger).and_return(logger) + + broadcast_module = Module.new + allow(ActiveSupport::Logger).to receive(:respond_to?).with(:broadcast).and_return(true) + allow(ActiveSupport::Logger).to receive(:broadcast).with(appender).and_return(broadcast_module) + + PostHog::Rails::Railtie.broadcast_rails_logger(appender) + + expect(logger).to have_received(:extend).with(broadcast_module) + end + end + end end From ef893c929055464f4adb29512c8172f724ad37d7 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 4 Jun 2026 14:41:30 -0400 Subject: [PATCH 02/30] fix(rails): drop incorrect service.version OTel resource attribute --- posthog-rails/lib/posthog/rails/logs/setup.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 0c50839..1714c7c 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -133,9 +133,13 @@ def build_provider(config, token) end def resource_attributes(config) + # service.version is intentionally omitted. Per OpenTelemetry semantic + # conventions it is the deployed application's version, not this gem's. + # The posthog-rails name/version travel with each record via the + # instrumentation scope (see LoggerProvider#logger above). Users can + # still set service.version through logs_resource_attributes. attrs = { 'service.name' => service_name, - 'service.version' => PostHog::VERSION, 'deployment.environment' => ::Rails.env.to_s } attrs.merge(stringify_keys(config.logs_resource_attributes || {})) From 33ba5e7f593a72c997c8ac5007d3947926c3ace1 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 4 Jun 2026 14:45:21 -0400 Subject: [PATCH 03/30] test(rails): cover all severity mappings in appender spec --- spec/posthog/rails/logs/appender_spec.rb | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index be961bc..401af13 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -37,12 +37,24 @@ def on_emit(**kwargs) expect(record[:severity_text]).to eq('INFO') end - it 'maps error severity' do - appender.error('boom') - - record = otel_logger.emitted.first - expect(record[:severity_number]).to eq(17) - expect(record[:severity_text]).to eq('ERROR') + # Covers every entry in Severity::MAPPING so a regression in any level + # (including the UNKNOWN -> INFO fallback) is caught. + { + debug: [5, 'DEBUG'], + info: [9, 'INFO'], + warn: [13, 'WARN'], + error: [17, 'ERROR'], + fatal: [21, 'FATAL'], + unknown: [9, 'INFO'] + }.each do |level_method, (number, text)| + it "maps #{level_method} to severity #{number} (#{text})" do + # Use a DEBUG-level appender so even debug records are emitted. + described_class.new(otel_logger, level: Logger::DEBUG).public_send(level_method, 'msg') + + record = otel_logger.emitted.first + expect(record[:severity_number]).to eq(number) + expect(record[:severity_text]).to eq(text) + end end it 'drops messages below the configured level' do From 921777cadd879717d7a148c1689f747b5e27638a Mon Sep 17 00:00:00 2001 From: John Nagro Date: Wed, 10 Jun 2026 18:05:37 -0400 Subject: [PATCH 04/30] refactor(rails): capture init options for logs instead of exposing client readers --- lib/posthog/client.rb | 8 ---- posthog-rails/lib/posthog/rails/logs/setup.rb | 37 ++++++++++++------- posthog-rails/lib/posthog/rails/railtie.rb | 4 ++ spec/posthog/rails/logs/setup_spec.rb | 16 ++++---- spec/posthog/rails/railtie_spec.rb | 11 ++++++ 5 files changed, 45 insertions(+), 31 deletions(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index ed5459e..219a0ae 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -51,13 +51,6 @@ def _decrement_instance_count(api_key) end end - # @return [String, nil] The project API key this client was initialized with - # (after whitespace trimming). Nil when the client is disabled. - attr_reader :api_key - - # @return [String] The fully qualified PostHog host this client was initialized with. - attr_reader :host - # @param opts [Hash] Client configuration. # @option opts [String, nil] :api_key Your project's API key. Missing or blank values disable the client. # @option opts [String, nil] :personal_api_key Your personal API key. Required for local feature flag evaluation. @@ -92,7 +85,6 @@ def initialize(opts = {}) @queue = Queue.new @api_key = opts[:api_key] - @host = opts[:host] @disabled = @api_key.nil? || @api_key.empty? @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE @worker_mutex = Mutex.new diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 1714c7c..db3b726 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -71,6 +71,20 @@ def shutdown! logger.warn("Error shutting down PostHog Logs: #{e.message}") end + # Remembers the api_key/host the PostHog client was initialized with + # (called by PostHog.init) so the logs pipeline can reuse them without + # the core client exposing public readers. + # + # @api private + # @param options [Hash] The options passed to {PostHog::Client.new}. + # @return [void] + def remember_client_options(options) + return unless options.is_a?(Hash) + + @client_api_key = options[:api_key] || options['api_key'] + @client_host = options[:host] || options['host'] + end + # Resets memoized state. Intended for tests. # # @return [void] @@ -79,33 +93,28 @@ def reset! @provider = nil @appender = nil @warned = false + @client_api_key = nil + @client_host = nil end private # The logs token is the same project token the core client uses - # (i.e. config.api_key), falling back to ENV['POSTHOG_API_KEY']. + # (i.e. config.api_key, captured by PostHog.init), falling back to + # ENV['POSTHOG_API_KEY']. def resolve_token - normalize(client_attribute(:api_key)) || normalize(ENV.fetch('POSTHOG_API_KEY', nil)) + normalize(@client_api_key) || normalize(ENV.fetch('POSTHOG_API_KEY', nil)) end - # The logs host follows the core client's configured host, falling back - # to ENV['POSTHOG_HOST'] and finally the US cloud endpoint. + # The logs host follows the core client's configured host (captured by + # PostHog.init), falling back to ENV['POSTHOG_HOST'] and finally the + # US cloud endpoint. def resolve_host - normalize(client_attribute(:host)) || + normalize(@client_host) || normalize(ENV.fetch('POSTHOG_HOST', nil)) || 'https://us.i.posthog.com' end - def client_attribute(name) - return nil unless PostHog.respond_to?(:client) - - client = PostHog.client - client.respond_to?(name) ? client.public_send(name) : nil - rescue StandardError - nil - end - def require_otel_gems require 'opentelemetry-sdk' require 'opentelemetry-logs-sdk' diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index 465e535..a31e583 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -34,6 +34,10 @@ def init(options = {}) options = config.to_client_options end + # Let the PostHog Logs pipeline reuse the same api_key/host without + # the core client exposing public readers. + PostHog::Rails::Logs::Setup.remember_client_options(options) if defined?(PostHog::Rails::Logs::Setup) + # Create the PostHog client @client = PostHog::Client.new(options) end diff --git a/spec/posthog/rails/logs/setup_spec.rb b/spec/posthog/rails/logs/setup_spec.rb index d678d83..3a5fa0b 100644 --- a/spec/posthog/rails/logs/setup_spec.rb +++ b/spec/posthog/rails/logs/setup_spec.rb @@ -86,9 +86,8 @@ stub_const('OpenTelemetry::Exporter::OTLP::Logs::LogsExporter', exporter_class) end - it 'derives the OTLP endpoint and bearer token from the configured client' do - allow(PostHog).to receive(:client) - .and_return(double('client', api_key: 'phc_token', host: 'https://us.i.posthog.com')) + it 'derives the OTLP endpoint and bearer token from the remembered init options' do + described_class.remember_client_options(api_key: 'phc_token', host: 'https://us.i.posthog.com') appender = described_class.install! @@ -97,16 +96,16 @@ expect(exporter_args[:headers]).to eq('Authorization' => 'Bearer phc_token') end - it 'follows the client host, stripping a trailing slash' do - allow(PostHog).to receive(:client) - .and_return(double('client', api_key: 'phc_token', host: 'https://eu.i.posthog.com/')) + it 'supports string-keyed init options and strips a trailing slash from the host' do + described_class.remember_client_options('api_key' => 'phc_token', 'host' => 'https://eu.i.posthog.com/') described_class.install! expect(exporter_args[:endpoint]).to eq('https://eu.i.posthog.com/i/v1/logs') + expect(exporter_args[:headers]).to eq('Authorization' => 'Bearer phc_token') end - it 'falls back to ENV for token and host when no client is configured' do + it 'falls back to ENV for token and host when no init options were captured' do allow(ENV).to receive(:fetch).and_call_original allow(ENV).to receive(:fetch).with('POSTHOG_API_KEY', nil).and_return('phc_env') allow(ENV).to receive(:fetch).with('POSTHOG_HOST', nil).and_return('https://eu.i.posthog.com') @@ -118,8 +117,7 @@ end it 'is idempotent and returns the same appender' do - allow(PostHog).to receive(:client) - .and_return(double('client', api_key: 'phc_token', host: 'https://us.i.posthog.com')) + described_class.remember_client_options(api_key: 'phc_token', host: 'https://us.i.posthog.com') first = described_class.install! expect(described_class.install!).to be(first) diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb index aaa3ac9..35c796e 100644 --- a/spec/posthog/rails/railtie_spec.rb +++ b/spec/posthog/rails/railtie_spec.rb @@ -107,6 +107,17 @@ after { PostHog.client = nil } + describe 'PostHog.init' do + it 'remembers the init options for the logs pipeline' do + allow(PostHog::Rails::Logs::Setup).to receive(:remember_client_options) + + PostHog.init(api_key: 'phc_test', host: 'https://eu.i.posthog.com', test_mode: true) + + expect(PostHog::Rails::Logs::Setup).to have_received(:remember_client_options) + .with(hash_including(api_key: 'phc_test', host: 'https://eu.i.posthog.com')) + end + end + describe '.install_posthog_logs' do it 'no-ops when PostHog is not initialized' do allow(PostHog::Rails::Logs::Setup).to receive(:install!) From c29ffe58edc3626b4f30c2986770a46495510e2b Mon Sep 17 00:00:00 2001 From: John Nagro Date: Wed, 10 Jun 2026 18:18:39 -0400 Subject: [PATCH 05/30] docs(rails): document Appender's lock-free thread-safety design --- posthog-rails/lib/posthog/rails/logs/appender.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index a626529..b9802fb 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -17,6 +17,13 @@ module Logs # with the request-scoped PostHog identity captured by # {PostHog::Rails::RequestContext}. # + # Thread-safety: intentionally lock-free. Emitting touches no shared + # mutable state (`@otel_logger` is assigned once, attributes are built + # per call, and `Internal::Context.current` is thread/fiber-local), and + # the OTel BatchLogRecordProcessor synchronizes its buffer internally — + # the same split as stdlib `Logger`, which locks in `LogDevice`, not + # `Logger#add`. A mutex here would serialize all app logging needlessly. + # # @api private class Appender < ::Logger SELF_LOG_PREFIX = '[posthog-ruby]' From fd95ed4c36e5cdc4adfc6094be385d77f3ca2ced Mon Sep 17 00:00:00 2001 From: John Nagro Date: Wed, 10 Jun 2026 18:29:44 -0400 Subject: [PATCH 06/30] fix(rails): match self-log prefix with start_with? to avoid over-suppression --- posthog-rails/lib/posthog/rails/logs/appender.rb | 5 ++++- spec/posthog/rails/logs/appender_spec.rb | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index b9802fb..f417de4 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -104,7 +104,10 @@ def attributes_for(progname) def self_log?(message, progname) return true if progname.to_s == SELF_LOG_PROGNAME - message.is_a?(String) && message.include?(SELF_LOG_PREFIX) + # PrefixedLogger always places the prefix at the start of the message, + # so start_with? suffices and avoids suppressing app logs that merely + # mention the SDK mid-string. + message.is_a?(String) && message.start_with?(SELF_LOG_PREFIX) end end end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 401af13..4374bb6 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -81,6 +81,12 @@ def on_emit(**kwargs) expect(otel_logger.emitted).to be_empty end + it 'does not suppress app logs that merely mention the SDK mid-string' do + appender.info('upstream failed: [posthog-ruby] timeout') + + expect(otel_logger.emitted.size).to eq(1) + end + it 'suppresses logs emitted under the PostHog progname' do appender.info('PostHog') { 'internal diagnostic' } From ca3c155049e97ccdbf36ae967ce595fbfb28fa67 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Wed, 10 Jun 2026 18:45:34 -0400 Subject: [PATCH 07/30] feat(rails): cap PostHog Logs forwarding at a configurable records-per-minute rate --- posthog-rails/README.md | 6 +++ posthog-rails/examples/posthog.rb | 4 ++ .../generators/posthog/templates/posthog.rb | 4 ++ posthog-rails/lib/posthog/rails.rb | 1 + .../lib/posthog/rails/configuration.rb | 5 ++ .../lib/posthog/rails/logs/appender.rb | 33 +++++++++--- .../lib/posthog/rails/logs/rate_limiter.rb | 48 +++++++++++++++++ posthog-rails/lib/posthog/rails/logs/setup.rb | 10 +++- spec/posthog/rails/configuration_spec.rb | 1 + spec/posthog/rails/logs/appender_spec.rb | 31 +++++++++++ spec/posthog/rails/logs/rate_limiter_spec.rb | 51 +++++++++++++++++++ 11 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 posthog-rails/lib/posthog/rails/logs/rate_limiter.rb create mode 100644 spec/posthog/rails/logs/rate_limiter_spec.rb diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 7c74a9b..1c3776b 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -33,3 +33,9 @@ end When the OpenTelemetry gems are absent, the feature logs a single warning and no-ops, so it is safe to enable conditionally. + +Forwarding is capped at 6,000 records per minute by default to protect your +ingestion quota from runaway log volume; when the cap trips, one warning record +is emitted and further records are dropped for the remainder of the window. +Tune or disable it with `config.logs_max_records_per_minute` (set to `nil` to +disable). diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index bda9882..9e1b97d 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -64,6 +64,10 @@ # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) # config.logs_level = :info + # Maximum records forwarded per minute, protecting your ingestion quota from + # runaway log volume (default: 6000; set to nil to disable the cap) + # config.logs_max_records_per_minute = 6_000 + # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 332882a..ddbd0f1 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -64,6 +64,10 @@ # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) # config.logs_level = :info + # Maximum records forwarded per minute, protecting your ingestion quota from + # runaway log volume (default: 6000; set to nil to disable the cap) + # config.logs_max_records_per_minute = 6_000 + # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index e4543b3..156fe51 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -9,6 +9,7 @@ require 'posthog/rails/active_job' require 'posthog/rails/error_subscriber' require 'posthog/rails/logs/severity' +require 'posthog/rails/logs/rate_limiter' require 'posthog/rails/logs/appender' require 'posthog/rails/logs/setup' require 'posthog/rails/railtie' diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 22ec2a7..0b9e731 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -44,6 +44,10 @@ class Configuration # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info). attr_accessor :logs_level + # @return [Integer, nil] Maximum log records forwarded to PostHog Logs per minute, protecting the + # ingestion quota from runaway log volume. Defaults to 6000. Set to nil to disable the cap. + attr_accessor :logs_max_records_per_minute + # @return [Hash] Extra OpenTelemetry resource attributes merged with auto-detected service metadata. attr_accessor :logs_resource_attributes @@ -60,6 +64,7 @@ def initialize @logs_enabled = false @forward_rails_logger = true @logs_level = nil + @logs_max_records_per_minute = 6_000 @logs_resource_attributes = {} end diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index f417de4..f8e41a5 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -17,12 +17,14 @@ module Logs # with the request-scoped PostHog identity captured by # {PostHog::Rails::RequestContext}. # - # Thread-safety: intentionally lock-free. Emitting touches no shared - # mutable state (`@otel_logger` is assigned once, attributes are built - # per call, and `Internal::Context.current` is thread/fiber-local), and - # the OTel BatchLogRecordProcessor synchronizes its buffer internally — - # the same split as stdlib `Logger`, which locks in `LogDevice`, not - # `Logger#add`. A mutex here would serialize all app logging needlessly. + # Thread-safety: intentionally lock-free apart from the optional rate + # limiter's counter. Emitting touches no shared mutable state + # (`@otel_logger` is assigned once, attributes are built per call, and + # `Internal::Context.current` is thread/fiber-local), and the OTel + # BatchLogRecordProcessor synchronizes its buffer internally — the same + # split as stdlib `Logger`, which locks in `LogDevice`, not + # `Logger#add`. A mutex around emit would serialize all app logging + # needlessly. # # @api private class Appender < ::Logger @@ -32,9 +34,12 @@ class Appender < ::Logger # @param otel_logger [#on_emit] An OpenTelemetry logger. # @param level [Integer, nil] Minimum severity to forward. - def initialize(otel_logger, level: nil) + # @param rate_limiter [PostHog::Rails::Logs::RateLimiter, nil] Optional cap on + # forwarded records, protecting the ingestion quota from runaway log volume. + def initialize(otel_logger, level: nil, rate_limiter: nil) super(nil) @otel_logger = otel_logger + @rate_limiter = rate_limiter self.level = level unless level.nil? end @@ -58,6 +63,20 @@ def add(severity, message = nil, progname = nil) return true if message.nil? return true if self_log?(message, progname) + case @rate_limiter&.record + when :reject + return true + when :reject_first + # One discoverable notice per window so truncation isn't silent. + emit( + ::Logger::WARN, + "PostHog Logs rate cap reached (#{@rate_limiter.limit} records/minute); " \ + 'dropping further records for the remainder of this window', + nil + ) + return true + end + emit(severity, message, progname) true rescue StandardError diff --git a/posthog-rails/lib/posthog/rails/logs/rate_limiter.rb b/posthog-rails/lib/posthog/rails/logs/rate_limiter.rb new file mode 100644 index 0000000..cb80b17 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/logs/rate_limiter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module PostHog + module Rails + module Logs + # Fixed-window rate limiter protecting the PostHog Logs ingestion quota + # from runaway log volume (PostHog Logs bills by data ingested). + # + # Thread-safe: the counter is the one piece of shared mutable state in + # the logs pipeline, guarded by a mutex scoped to a counter bump. + # + # @api private + class RateLimiter + WINDOW_SECONDS = 60 + + # @return [Integer] Maximum records allowed per window. + attr_reader :limit + + # @param limit [Integer] Maximum records allowed per {WINDOW_SECONDS} window. + def initialize(limit) + @limit = limit + @mutex = Mutex.new + @window = nil + @count = 0 + end + + # Records one attempt and returns the verdict. + # + # @return [Symbol] :allow when under the cap, :reject_first for the + # first rejection of a window (callers may emit a single notice), + # :reject thereafter. + def record + @mutex.synchronize do + window = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i / WINDOW_SECONDS + if window != @window + @window = window + @count = 0 + end + @count += 1 + next :allow if @count <= @limit + + @count == @limit + 1 ? :reject_first : :reject + end + end + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index db3b726..e0922a6 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -3,6 +3,7 @@ require 'logger' require 'posthog/logging' require 'posthog/rails/logs/appender' +require 'posthog/rails/logs/rate_limiter' module PostHog module Rails @@ -47,7 +48,7 @@ def install! @provider = build_provider(config, token) otel_logger = @provider.logger(name: 'posthog-rails', version: PostHog::VERSION) level = resolve_level(config.logs_level) || rails_logger_level - @appender = Appender.new(otel_logger, level: level) + @appender = Appender.new(otel_logger, level: level, rate_limiter: build_rate_limiter(config)) rescue StandardError => e warn_once("Failed to initialize PostHog Logs: #{e.message}") nil @@ -115,6 +116,13 @@ def resolve_host 'https://us.i.posthog.com' end + def build_rate_limiter(config) + limit = config.logs_max_records_per_minute + return nil unless limit.is_a?(Numeric) && limit.positive? + + RateLimiter.new(limit.to_i) + end + def require_otel_gems require 'opentelemetry-sdk' require 'opentelemetry-logs-sdk' diff --git a/spec/posthog/rails/configuration_spec.rb b/spec/posthog/rails/configuration_spec.rb index d5c423f..0e9724a 100644 --- a/spec/posthog/rails/configuration_spec.rb +++ b/spec/posthog/rails/configuration_spec.rb @@ -45,6 +45,7 @@ expect(config.logs_enabled).to be false expect(config.forward_rails_logger).to be true expect(config.logs_level).to be_nil + expect(config.logs_max_records_per_minute).to eq(6_000) expect(config.logs_resource_attributes).to eq({}) end end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 4374bb6..cba732d 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -5,6 +5,7 @@ $LOAD_PATH.unshift File.expand_path('../../../../posthog-rails/lib', __dir__) require 'posthog/rails/logs/appender' +require 'posthog/rails/logs/rate_limiter' RSpec.describe PostHog::Rails::Logs::Appender do let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } @@ -101,6 +102,36 @@ def on_emit(**kwargs) end end + describe 'rate limiting' do + let(:rate_limiter) { PostHog::Rails::Logs::RateLimiter.new(2) } + + subject(:appender) { described_class.new(otel_logger, level: Logger::INFO, rate_limiter: rate_limiter) } + + it 'forwards records while under the cap' do + 2.times { appender.info('fine') } + + expect(otel_logger.emitted.size).to eq(2) + end + + it 'emits a single cap notice, then drops silently for the rest of the window' do + 5.times { |i| appender.info("msg #{i}") } + + expect(otel_logger.emitted.size).to eq(3) + notice = otel_logger.emitted.last + expect(notice[:body]).to include('rate cap reached (2 records/minute)') + expect(notice[:severity_text]).to eq('WARN') + end + + it 'does not count records filtered by level or self-log suppression' do + appender.debug('below level') + appender.info('[posthog-ruby] internal diagnostic') + 2.times { appender.info('fine') } + + expect(otel_logger.emitted.size).to eq(2) + expect(otel_logger.emitted.map { |r| r[:body] }).to all(eq('fine')) + end + end + describe 'context correlation' do it 'stamps the request distinct_id, session_id, and request metadata' do context_class.with_context( diff --git a/spec/posthog/rails/logs/rate_limiter_spec.rb b/spec/posthog/rails/logs/rate_limiter_spec.rb new file mode 100644 index 0000000..d2abbe2 --- /dev/null +++ b/spec/posthog/rails/logs/rate_limiter_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +$LOAD_PATH.unshift File.expand_path('../../../../posthog-rails/lib', __dir__) + +require 'posthog/rails/logs/rate_limiter' + +RSpec.describe PostHog::Rails::Logs::RateLimiter do + subject(:limiter) { described_class.new(3) } + + def stub_monotonic_time(seconds) + allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(seconds) + end + + it 'allows records up to the limit' do + stub_monotonic_time(0) + + expect(Array.new(3) { limiter.record }).to all(eq(:allow)) + end + + it 'returns :reject_first exactly once per window, then :reject' do + stub_monotonic_time(0) + 3.times { limiter.record } + + expect(limiter.record).to eq(:reject_first) + expect(limiter.record).to eq(:reject) + expect(limiter.record).to eq(:reject) + end + + it 'resets the counter in a new window' do + stub_monotonic_time(0) + 4.times { limiter.record } + expect(limiter.record).to eq(:reject) + + stub_monotonic_time(described_class::WINDOW_SECONDS) + expect(limiter.record).to eq(:allow) + end + + it 'counts concurrent records without losing increments' do + stub_monotonic_time(0) + limiter = described_class.new(100) + + threads = Array.new(4) { Thread.new { Array.new(50) { limiter.record } } } + verdicts = threads.flat_map(&:value) + + expect(verdicts.count(:allow)).to eq(100) + expect(verdicts.count(:reject_first)).to eq(1) + expect(verdicts.count(:reject)).to eq(99) + end +end From 0b07c9bc7a2efc3ab7ba20c629979b1b961f5bea Mon Sep 17 00:00:00 2001 From: John Nagro Date: Wed, 10 Jun 2026 18:52:20 -0400 Subject: [PATCH 08/30] feat(rails): add logs_before_send callback to scrub or drop PostHog Logs records --- posthog-rails/README.md | 5 ++ posthog-rails/examples/posthog.rb | 9 ++++ .../generators/posthog/templates/posthog.rb | 9 ++++ .../lib/posthog/rails/configuration.rb | 7 +++ .../lib/posthog/rails/logs/appender.rb | 41 +++++++++++++++- posthog-rails/lib/posthog/rails/logs/setup.rb | 7 ++- spec/posthog/rails/configuration_spec.rb | 1 + spec/posthog/rails/logs/appender_spec.rb | 48 +++++++++++++++++++ 8 files changed, 124 insertions(+), 3 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 1c3776b..6ea6b3f 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -39,3 +39,8 @@ ingestion quota from runaway log volume; when the cap trips, one warning record is emitted and further records are dropped for the remainder of the window. Tune or disable it with `config.logs_max_records_per_minute` (set to `nil` to disable). + +To scrub PII (or drop records entirely) before they leave the app, set +`config.logs_before_send` to a proc that receives each record hash and returns +a modified hash to send or `nil` to drop it. If the callback raises, the +record is dropped. diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 9e1b97d..03e0eea 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -68,6 +68,15 @@ # runaway log volume (default: 6000; set to nil to disable the cap) # config.logs_max_records_per_minute = 6_000 + # Modify or drop log records before they are sent, e.g. to scrub PII. + # Receives a hash (:timestamp, :severity_number, :severity_text, :body, + # :attributes); return the (modified) hash to send or nil to drop. Records + # are dropped if the callback raises. (default: nil) + # config.logs_before_send = proc { |record| + # record[:body] = record[:body].gsub(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/, '[redacted email]') + # record + # } + # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index ddbd0f1..6c90769 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -68,6 +68,15 @@ # runaway log volume (default: 6000; set to nil to disable the cap) # config.logs_max_records_per_minute = 6_000 + # Modify or drop log records before they are sent, e.g. to scrub PII. + # Receives a hash (:timestamp, :severity_number, :severity_text, :body, + # :attributes); return the (modified) hash to send or nil to drop. Records + # are dropped if the callback raises. (default: nil) + # config.logs_before_send = proc { |record| + # record[:body] = record[:body].gsub(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/, '[redacted email]') + # record + # } + # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 0b9e731..1ed462c 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -48,6 +48,12 @@ class Configuration # ingestion quota from runaway log volume. Defaults to 6000. Set to nil to disable the cap. attr_accessor :logs_max_records_per_minute + # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity_number, + # :severity_text, :body, :attributes) before it is sent to PostHog Logs. Return a (possibly + # modified) hash to send, or nil to drop the record — useful for scrubbing PII. If the callback + # raises, the record is dropped. Defaults to nil. + attr_accessor :logs_before_send + # @return [Hash] Extra OpenTelemetry resource attributes merged with auto-detected service metadata. attr_accessor :logs_resource_attributes @@ -65,6 +71,7 @@ def initialize @forward_rails_logger = true @logs_level = nil @logs_max_records_per_minute = 6_000 + @logs_before_send = nil @logs_resource_attributes = {} end diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index f8e41a5..b1f1b77 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -3,6 +3,7 @@ require 'logger' require 'time' require 'posthog/internal/context' +require 'posthog/logging' require 'posthog/rails/logs/severity' module PostHog @@ -36,10 +37,15 @@ class Appender < ::Logger # @param level [Integer, nil] Minimum severity to forward. # @param rate_limiter [PostHog::Rails::Logs::RateLimiter, nil] Optional cap on # forwarded records, protecting the ingestion quota from runaway log volume. - def initialize(otel_logger, level: nil, rate_limiter: nil) + # @param before_send [#call, nil] Optional callback invoked with each record hash + # (:timestamp, :severity_number, :severity_text, :body, :attributes) before it + # is emitted. Return a (possibly modified) hash to send, or nil to drop — + # useful for scrubbing PII. If the callback raises, the record is dropped. + def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil) super(nil) @otel_logger = otel_logger @rate_limiter = rate_limiter + @before_send = before_send self.level = level unless level.nil? end @@ -88,12 +94,43 @@ def add(severity, message = nil, progname = nil) def emit(severity, message, progname) severity_number, severity_text = Severity.for(severity) - @otel_logger.on_emit( + record = { timestamp: Time.now, severity_number: severity_number, severity_text: severity_text, body: body_for(message), attributes: attributes_for(progname) + } + record = apply_before_send(record) + return if record.nil? + + @otel_logger.on_emit(**record) + end + + # Runs after the rate-cap check so a log flood does not pay scrubbing + # costs for records that would be dropped anyway. + # + # Unlike the events before_send (which sends the original event when the + # callback raises), a failing callback drops the record: the likeliest + # use is PII scrubbing, where shipping the unscrubbed original is worse + # than losing the line. + def apply_before_send(record) + return record unless @before_send + + result = @before_send.call(record) + result.is_a?(Hash) ? result : nil + rescue StandardError => e + warn_before_send_error(e) + nil + end + + def warn_before_send_error(error) + # Benign race: concurrent first failures may warn more than once. + return if @before_send_error_warned + + @before_send_error_warned = true + PostHog::Logging.logger.warn( + "logs_before_send raised (#{error.class}: #{error.message}); dropping records that fail the callback" ) end diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index e0922a6..a668e40 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -48,7 +48,12 @@ def install! @provider = build_provider(config, token) otel_logger = @provider.logger(name: 'posthog-rails', version: PostHog::VERSION) level = resolve_level(config.logs_level) || rails_logger_level - @appender = Appender.new(otel_logger, level: level, rate_limiter: build_rate_limiter(config)) + @appender = Appender.new( + otel_logger, + level: level, + rate_limiter: build_rate_limiter(config), + before_send: config.logs_before_send + ) rescue StandardError => e warn_once("Failed to initialize PostHog Logs: #{e.message}") nil diff --git a/spec/posthog/rails/configuration_spec.rb b/spec/posthog/rails/configuration_spec.rb index 0e9724a..b58e39d 100644 --- a/spec/posthog/rails/configuration_spec.rb +++ b/spec/posthog/rails/configuration_spec.rb @@ -46,6 +46,7 @@ expect(config.forward_rails_logger).to be true expect(config.logs_level).to be_nil expect(config.logs_max_records_per_minute).to eq(6_000) + expect(config.logs_before_send).to be_nil expect(config.logs_resource_attributes).to eq({}) end end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index cba732d..e7fa001 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -132,6 +132,54 @@ def on_emit(**kwargs) end end + describe 'before_send' do + it 'sends the record returned by the callback' do + before_send = proc { |record| record.merge(body: record[:body].gsub('secret', '[redacted]')) } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + appender.info('the secret token') + + expect(otel_logger.emitted.first[:body]).to eq('the [redacted] token') + end + + it 'drops the record when the callback returns nil' do + before_send = proc { |record| record[:body].include?('secret') ? nil : record } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + appender.info('the secret token') + appender.info('all clear') + + expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['all clear']) + end + + it 'drops the record (rather than sending it unscrubbed) when the callback raises' do + before_send = proc { |_record| raise 'scrubber bug' } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + expect { appender.info('the secret token') }.not_to raise_error + expect(otel_logger.emitted).to be_empty + end + + it 'is not invoked for records already dropped by the rate cap' do + calls = 0 + before_send = proc do |record| + calls += 1 + record + end + appender = described_class.new( + otel_logger, + level: Logger::INFO, + rate_limiter: PostHog::Rails::Logs::RateLimiter.new(1), + before_send: before_send + ) + + 3.times { appender.info('msg') } + + # Invoked for the one allowed record and the cap notice, not the drops. + expect(calls).to eq(2) + end + end + describe 'context correlation' do it 'stamps the request distinct_id, session_id, and request metadata' do context_class.with_context( From 532165a89f83415aa0c96691d81bfe0163c6ec64 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:56:07 -0400 Subject: [PATCH 09/30] Update posthog-rails/lib/posthog/rails/logs/appender.rb Co-authored-by: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> --- posthog-rails/lib/posthog/rails/logs/appender.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index b1f1b77..daaf6f9 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -135,7 +135,11 @@ def warn_before_send_error(error) end def body_for(message) - message.is_a?(String) ? message : message.inspect + str = message.is_a?(String) ? message.dup : message.inspect + unless str.encoding == Encoding::UTF_8 + str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + str.valid_encoding? ? str : str.scrub end def attributes_for(progname) From 8b458267923bca80cb2fa75b58984191dbd26434 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:18:57 -0400 Subject: [PATCH 10/30] fix: fallback to 'unknown_service' rather than 'rails' --- posthog-rails/lib/posthog/rails/logs/setup.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index a668e40..6711394 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -169,12 +169,12 @@ def resource_attributes(config) def service_name app = ::Rails.application - return 'rails' unless app + return 'unknown_service' unless app name = app.class.respond_to?(:module_parent_name) ? app.class.module_parent_name : nil - name && !name.empty? ? name.to_s : 'rails' + name && !name.empty? ? name.to_s : 'unknown_service' rescue StandardError - 'rails' + 'unknown_service' end def logs_endpoint(host) From e382f3cd6c657b99139a6b5a0d44a627c9d30724 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:21:46 -0400 Subject: [PATCH 11/30] fix(rails): emit progname as logger.name to match OTel logger-name convention --- posthog-rails/lib/posthog/rails/logs/appender.rb | 4 +++- spec/posthog/rails/logs/appender_spec.rb | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index daaf6f9..0cf5d07 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -144,7 +144,9 @@ def body_for(message) def attributes_for(progname) attributes = {} - attributes['logger.progname'] = progname.to_s if progname + # Ruby's progname is the closest analog to the OTel-world "logger name"; + # logger.name is the key users coming from other ecosystems will expect. + attributes['logger.name'] = progname.to_s if progname context = Internal::Context.current return attributes unless context diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index e7fa001..903deab 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -76,6 +76,12 @@ def on_emit(**kwargs) expect(otel_logger.emitted.first[:body]).to eq('["a", "b"]') end + it 'stamps the progname as the OTel-conventional logger.name attribute' do + appender.info('MyJob') { 'job ran' } + + expect(otel_logger.emitted.first[:attributes]['logger.name']).to eq('MyJob') + end + it 'suppresses self-logs carrying the posthog-ruby prefix' do appender.info('[posthog-ruby] internal diagnostic') From b5836cdead00c70be65eca53d16e3bc25ba6ee3e Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:29:50 -0400 Subject: [PATCH 12/30] docs(rails): note automatic trace_id/span_id correlation for apps using OTel tracing --- posthog-rails/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 6ea6b3f..6fadeb6 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -44,3 +44,7 @@ To scrub PII (or drop records entirely) before they leave the app, set `config.logs_before_send` to a proc that receives each record hash and returns a modified hash to send or `nil` to drop it. If the callback raises, the record is dropped. + +If your app already uses OpenTelemetry tracing, log records emitted during a +traced request automatically carry the active `trace_id`/`span_id` — no +configuration needed. From 318e7d3fd83c268de93d16b7648636ae6703717f Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:41:56 -0400 Subject: [PATCH 13/30] refactor(rails): expose a single :severity enum in the logs_before_send record hash --- posthog-rails/examples/posthog.rb | 8 ++-- .../generators/posthog/templates/posthog.rb | 8 ++-- .../lib/posthog/rails/configuration.rb | 8 ++-- .../lib/posthog/rails/logs/appender.rb | 22 +++++++---- .../lib/posthog/rails/logs/severity.rb | 37 ++++++++++++++----- spec/posthog/rails/logs/appender_spec.rb | 37 ++++++++++++++++++- 6 files changed, 93 insertions(+), 27 deletions(-) diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 03e0eea..006f6d6 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -69,10 +69,12 @@ # config.logs_max_records_per_minute = 6_000 # Modify or drop log records before they are sent, e.g. to scrub PII. - # Receives a hash (:timestamp, :severity_number, :severity_text, :body, - # :attributes); return the (modified) hash to send or nil to drop. Records - # are dropped if the callback raises. (default: nil) + # Receives a hash (:timestamp, :severity, :body, :attributes — :severity is + # a symbol such as :warn); return the (modified) hash to send or nil to + # drop. Records are dropped if the callback raises. (default: nil) # config.logs_before_send = proc { |record| + # next nil if record[:severity] == :debug + # # record[:body] = record[:body].gsub(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/, '[redacted email]') # record # } diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 6c90769..7ad56a3 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -69,10 +69,12 @@ # config.logs_max_records_per_minute = 6_000 # Modify or drop log records before they are sent, e.g. to scrub PII. - # Receives a hash (:timestamp, :severity_number, :severity_text, :body, - # :attributes); return the (modified) hash to send or nil to drop. Records - # are dropped if the callback raises. (default: nil) + # Receives a hash (:timestamp, :severity, :body, :attributes — :severity is + # a symbol such as :warn); return the (modified) hash to send or nil to + # drop. Records are dropped if the callback raises. (default: nil) # config.logs_before_send = proc { |record| + # next nil if record[:severity] == :debug + # # record[:body] = record[:body].gsub(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/, '[redacted email]') # record # } diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 1ed462c..bc30d4f 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -48,10 +48,10 @@ class Configuration # ingestion quota from runaway log volume. Defaults to 6000. Set to nil to disable the cap. attr_accessor :logs_max_records_per_minute - # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity_number, - # :severity_text, :body, :attributes) before it is sent to PostHog Logs. Return a (possibly - # modified) hash to send, or nil to drop the record — useful for scrubbing PII. If the callback - # raises, the record is dropped. Defaults to nil. + # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity, :body, + # :attributes — where :severity is a symbol such as :warn) before it is sent to PostHog Logs. + # Return a (possibly modified) hash to send, or nil to drop the record — useful for scrubbing + # PII. If the callback raises, the record is dropped. Defaults to nil. attr_accessor :logs_before_send # @return [Hash] Extra OpenTelemetry resource attributes merged with auto-detected service metadata. diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 0cf5d07..52e1f3a 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -38,9 +38,10 @@ class Appender < ::Logger # @param rate_limiter [PostHog::Rails::Logs::RateLimiter, nil] Optional cap on # forwarded records, protecting the ingestion quota from runaway log volume. # @param before_send [#call, nil] Optional callback invoked with each record hash - # (:timestamp, :severity_number, :severity_text, :body, :attributes) before it - # is emitted. Return a (possibly modified) hash to send, or nil to drop — - # useful for scrubbing PII. If the callback raises, the record is dropped. + # (:timestamp, :severity, :body, :attributes — where :severity is a symbol such + # as :warn) before it is emitted. Return a (possibly modified) hash to send, or + # nil to drop — useful for scrubbing PII. If the callback raises, the record is + # dropped. def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil) super(nil) @otel_logger = otel_logger @@ -93,18 +94,25 @@ def add(severity, message = nil, progname = nil) private def emit(severity, message, progname) - severity_number, severity_text = Severity.for(severity) record = { timestamp: Time.now, - severity_number: severity_number, - severity_text: severity_text, + severity: Severity.name_for(severity), body: body_for(message), attributes: attributes_for(progname) } record = apply_before_send(record) return if record.nil? - @otel_logger.on_emit(**record) + # The callback sees a single :severity enum; the OTel number/text pair + # is derived here so the two can never be set inconsistently. + severity_number, severity_text = Severity.for_name(record[:severity]) + @otel_logger.on_emit( + timestamp: record[:timestamp], + severity_number: severity_number, + severity_text: severity_text, + body: record[:body], + attributes: record[:attributes] + ) end # Runs after the rate-cap check so a log flood does not pay scrubbing diff --git a/posthog-rails/lib/posthog/rails/logs/severity.rb b/posthog-rails/lib/posthog/rails/logs/severity.rb index 538efcc..46ac020 100644 --- a/posthog-rails/lib/posthog/rails/logs/severity.rb +++ b/posthog-rails/lib/posthog/rails/logs/severity.rb @@ -17,19 +17,38 @@ module Severity # @param severity [Integer, nil] A Ruby `Logger` severity constant. # @return [Array(Integer, String)] OpenTelemetry severity number and text. def for(severity) - MAPPING.fetch(severity, DEFAULT) + for_name(name_for(severity)) end - MAPPING = { - ::Logger::DEBUG => [5, 'DEBUG'], - ::Logger::INFO => [9, 'INFO'], - ::Logger::WARN => [13, 'WARN'], - ::Logger::ERROR => [17, 'ERROR'], - ::Logger::FATAL => [21, 'FATAL'], - ::Logger::UNKNOWN => [9, 'INFO'] + # @param severity [Integer, nil] A Ruby `Logger` severity constant. + # @return [Symbol] The severity name (:debug, :info, :warn, :error, :fatal). + def name_for(severity) + NAMES.fetch(severity, :info) + end + + # @param name [Symbol, String, nil] A severity name such as :warn. + # @return [Array(Integer, String)] OpenTelemetry severity number and text; + # unrecognized names fall back to INFO. + def for_name(name) + OTEL.fetch(name.to_s.downcase.to_sym, OTEL[:info]) + end + + NAMES = { + ::Logger::DEBUG => :debug, + ::Logger::INFO => :info, + ::Logger::WARN => :warn, + ::Logger::ERROR => :error, + ::Logger::FATAL => :fatal, + ::Logger::UNKNOWN => :info }.freeze - DEFAULT = [9, 'INFO'].freeze + OTEL = { + debug: [5, 'DEBUG'], + info: [9, 'INFO'], + warn: [13, 'WARN'], + error: [17, 'ERROR'], + fatal: [21, 'FATAL'] + }.freeze end end end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 903deab..623be47 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -38,7 +38,7 @@ def on_emit(**kwargs) expect(record[:severity_text]).to eq('INFO') end - # Covers every entry in Severity::MAPPING so a regression in any level + # Covers every Ruby Logger severity so a regression in any level # (including the UNKNOWN -> INFO fallback) is caught. { debug: [5, 'DEBUG'], @@ -148,6 +148,41 @@ def on_emit(**kwargs) expect(otel_logger.emitted.first[:body]).to eq('the [redacted] token') end + it 'exposes the severity as a symbol enum' do + seen = nil + before_send = proc do |record| + seen = record[:severity] + record + end + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + appender.warn('careful') + + expect(seen).to eq(:warn) + end + + it 'derives the OTel severity pair from a severity changed by the callback' do + before_send = proc { |record| record.merge(severity: :error) } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + appender.info('actually an error') + + record = otel_logger.emitted.first + expect(record[:severity_number]).to eq(17) + expect(record[:severity_text]).to eq('ERROR') + end + + it 'falls back to INFO when the callback sets an unrecognized severity' do + before_send = proc { |record| record.merge(severity: :loud) } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + appender.warn('odd level') + + record = otel_logger.emitted.first + expect(record[:severity_number]).to eq(9) + expect(record[:severity_text]).to eq('INFO') + end + it 'drops the record when the callback returns nil' do before_send = proc { |record| record[:body].include?('secret') ? nil : record } appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) From 8329bf420602da507774987468ff07eadd9f5aa1 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:45:20 -0400 Subject: [PATCH 14/30] fix(rails): run logs_before_send before the rate cap so dropped records don't consume budget --- .../lib/posthog/rails/logs/appender.rb | 45 ++++++++++++------- spec/posthog/rails/logs/appender_spec.rb | 32 +++++++++---- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 52e1f3a..565e949 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -70,21 +70,18 @@ def add(severity, message = nil, progname = nil) return true if message.nil? return true if self_log?(message, progname) + record = apply_before_send(build_record(severity, message, progname)) + return true if record.nil? + case @rate_limiter&.record when :reject return true when :reject_first - # One discoverable notice per window so truncation isn't silent. - emit( - ::Logger::WARN, - "PostHog Logs rate cap reached (#{@rate_limiter.limit} records/minute); " \ - 'dropping further records for the remainder of this window', - nil - ) + emit_rate_cap_notice return true end - emit(severity, message, progname) + emit(record) true rescue StandardError # Never let log forwarding break the calling code path. @@ -93,18 +90,19 @@ def add(severity, message = nil, progname = nil) private - def emit(severity, message, progname) - record = { + def build_record(severity, message, progname) + { timestamp: Time.now, severity: Severity.name_for(severity), body: body_for(message), attributes: attributes_for(progname) } - record = apply_before_send(record) - return if record.nil? + end - # The callback sees a single :severity enum; the OTel number/text pair - # is derived here so the two can never be set inconsistently. + def emit(record) + # The before_send callback sees a single :severity enum; the OTel + # number/text pair is derived here so the two can never be set + # inconsistently. severity_number, severity_text = Severity.for_name(record[:severity]) @otel_logger.on_emit( timestamp: record[:timestamp], @@ -115,8 +113,23 @@ def emit(severity, message, progname) ) end - # Runs after the rate-cap check so a log flood does not pay scrubbing - # costs for records that would be dropped anyway. + # One discoverable notice per window so truncation isn't silent. Emitted + # directly (bypassing before_send) so a scrubber can't accidentally + # suppress the only signal that records are being dropped. + def emit_rate_cap_notice + emit( + timestamp: Time.now, + severity: :warn, + body: "PostHog Logs rate cap reached (#{@rate_limiter.limit} records/minute); " \ + 'dropping further records for the remainder of this window', + attributes: {} + ) + end + + # Runs before the rate-cap check (matching the other PostHog SDKs) so + # records dropped by the callback never consume window budget — a + # before_send that drops noisy logs must not starve the legitimate + # records behind them. # # Unlike the events before_send (which sends the original event when the # callback raises), a failing callback drops the record: the likeliest diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 623be47..c4ad703 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -201,12 +201,26 @@ def on_emit(**kwargs) expect(otel_logger.emitted).to be_empty end - it 'is not invoked for records already dropped by the rate cap' do - calls = 0 - before_send = proc do |record| - calls += 1 - record - end + # Cross-SDK spec: before_send runs before the rate cap, so callback-dropped + # records never consume window budget. + it 'does not charge callback-dropped records against the rate-cap budget' do + before_send = proc { |record| record[:body].include?('noise') ? nil : record } + appender = described_class.new( + otel_logger, + level: Logger::INFO, + rate_limiter: PostHog::Rails::Logs::RateLimiter.new(1), + before_send: before_send + ) + + appender.info('noise 1') + appender.info('noise 2') + appender.info('keep me') + + expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['keep me']) + end + + it 'emits the cap notice directly, bypassing the callback' do + before_send = proc { |record| record.merge(body: record[:body].upcase) } appender = described_class.new( otel_logger, level: Logger::INFO, @@ -216,8 +230,10 @@ def on_emit(**kwargs) 3.times { appender.info('msg') } - # Invoked for the one allowed record and the cap notice, not the drops. - expect(calls).to eq(2) + expect(otel_logger.emitted.size).to eq(2) + expect(otel_logger.emitted.first[:body]).to eq('MSG') + # The notice body is untouched by the callback. + expect(otel_logger.emitted.last[:body]).to include('rate cap reached (1 records/minute)') end end From c29da56b8e7ad0ec7d8e29ae12a63505a704434d Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:51:54 -0400 Subject: [PATCH 15/30] fix(rails): use OTel semconv names (url.full, http.request.method, url.path) for log request attributes --- posthog-rails/lib/posthog/rails/logs/appender.rb | 13 ++++++++++--- spec/posthog/rails/logs/appender_spec.rb | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 565e949..5c3e633 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -31,7 +31,14 @@ module Logs class Appender < ::Logger SELF_LOG_PREFIX = '[posthog-ruby]' SELF_LOG_PROGNAME = 'PostHog' - REQUEST_ATTRIBUTE_KEYS = %w[$current_url $request_method $request_path].freeze + # Maps PostHog event-property names (as stored in Internal::Context) to + # the OTel semantic-convention attribute names used on log records, + # matching the web SDK so one filter works across SDKs. + REQUEST_ATTRIBUTE_NAMES = { + '$current_url' => 'url.full', + '$request_method' => 'http.request.method', + '$request_path' => 'url.path' + }.freeze # @param otel_logger [#on_emit] An OpenTelemetry logger. # @param level [Integer, nil] Minimum severity to forward. @@ -176,9 +183,9 @@ def attributes_for(progname) attributes['sessionId'] = context.session_id if context.session_id properties = context.properties || {} - REQUEST_ATTRIBUTE_KEYS.each do |key| + REQUEST_ATTRIBUTE_NAMES.each do |key, attribute_name| value = properties[key] || properties[key.to_sym] - attributes[key] = value if value + attributes[attribute_name] = value if value end attributes diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index c4ad703..5bd1a7a 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -250,8 +250,11 @@ def on_emit(**kwargs) attributes = otel_logger.emitted.first[:attributes] expect(attributes['posthogDistinctId']).to eq('user-42') expect(attributes['sessionId']).to eq('session-99') - expect(attributes['$current_url']).to eq('https://example.com/widgets') - expect(attributes['$request_method']).to eq('GET') + # Request metadata uses OTel semconv names (matching the web SDK), not + # the $-prefixed PostHog event-property names stored in the context. + expect(attributes['url.full']).to eq('https://example.com/widgets') + expect(attributes['http.request.method']).to eq('GET') + expect(attributes).not_to have_key('$current_url') end it 'omits correlation attributes when there is no active context' do From 0c8a179e3c44015b55c1c880af635eaed27899b2 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 20:58:21 -0400 Subject: [PATCH 16/30] test(rails): cover body UTF-8 normalization and before_send string safety --- .../lib/posthog/rails/logs/appender.rb | 4 +- spec/posthog/rails/logs/appender_spec.rb | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 5c3e633..328111b 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -164,9 +164,7 @@ def warn_before_send_error(error) def body_for(message) str = message.is_a?(String) ? message.dup : message.inspect - unless str.encoding == Encoding::UTF_8 - str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) - end + str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8 str.valid_encoding? ? str : str.scrub end diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 5bd1a7a..ec04f2a 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -108,6 +108,28 @@ def on_emit(**kwargs) end end + # One body with bad encoding would otherwise fail protobuf encoding in the + # OTLP exporter and silently drop the whole batch. + describe 'body encoding safety' do + it 'converts non-UTF-8 strings to valid UTF-8' do + appender.info("caf\xE9".b.force_encoding(Encoding::ISO_8859_1)) + + body = otel_logger.emitted.first[:body] + expect(body.encoding).to eq(Encoding::UTF_8) + expect(body.valid_encoding?).to be true + expect(body).to eq('café') + end + + it 'scrubs invalid bytes from strings already tagged as UTF-8' do + appender.info("bad \xFF byte".b.force_encoding(Encoding::UTF_8)) + + body = otel_logger.emitted.first[:body] + expect(body.encoding).to eq(Encoding::UTF_8) + expect(body.valid_encoding?).to be true + expect(body).to include('bad').and include('byte') + end + end + describe 'rate limiting' do let(:rate_limiter) { PostHog::Rails::Logs::RateLimiter.new(2) } @@ -148,6 +170,21 @@ def on_emit(**kwargs) expect(otel_logger.emitted.first[:body]).to eq('the [redacted] token') end + it 'receives a mutable copy, so mutating callbacks cannot touch (or trip over) frozen app strings' do + original = 'user secret data' + before_send = proc do |record| + record[:body].gsub!('secret', '[redacted]') + record + end + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + + expect { appender.info(original) }.not_to raise_error + expect(otel_logger.emitted.first[:body]).to eq('user [redacted] data') + # The app's string (frozen via this file's frozen_string_literal magic + # comment) is untouched. + expect(original).to eq('user secret data') + end + it 'exposes the severity as a symbol enum' do seen = nil before_send = proc do |record| From bf8d01ecd35207c7169d4197de43e5162d77d713 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:01:48 -0400 Subject: [PATCH 17/30] fix(rails): warn when logs_before_send returns a non-Hash value instead of silently dropping --- .../lib/posthog/rails/logs/appender.rb | 19 ++++++++------- spec/posthog/rails/logs/appender_spec.rb | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 328111b..2319c4d 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -146,20 +146,23 @@ def apply_before_send(record) return record unless @before_send result = @before_send.call(record) - result.is_a?(Hash) ? result : nil + return result if result.is_a?(Hash) + + # nil is an intentional drop and stays silent; any other type is + # likely a bug (e.g. a proc whose last expression isn't the record). + warn_before_send("returned #{result.class} instead of a Hash or nil") unless result.nil? + nil rescue StandardError => e - warn_before_send_error(e) + warn_before_send("raised (#{e.class}: #{e.message})") nil end - def warn_before_send_error(error) + def warn_before_send(description) # Benign race: concurrent first failures may warn more than once. - return if @before_send_error_warned + return if @before_send_warned - @before_send_error_warned = true - PostHog::Logging.logger.warn( - "logs_before_send raised (#{error.class}: #{error.message}); dropping records that fail the callback" - ) + @before_send_warned = true + PostHog::Logging.logger.warn("logs_before_send #{description}; dropping the record") end def body_for(message) diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index ec04f2a..de08af2 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -230,6 +230,29 @@ def on_emit(**kwargs) expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['all clear']) end + it 'does not warn when the callback intentionally drops via nil' do + # An always-drop callback. + before_send = proc { |_record| } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + expect(PostHog::Logging.logger).not_to receive(:warn) + + appender.info('drop me') + + expect(otel_logger.emitted).to be_empty + end + + it 'drops and warns once when the callback returns a non-Hash, non-nil value' do + # A likely bug: the proc's last expression is the gsub! result, not the record. + before_send = proc { |record| record[:body].gsub!('a', 'b') } + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + expect(PostHog::Logging.logger) + .to receive(:warn).with(/logs_before_send returned String instead of a Hash or nil/).once + + 2.times { appender.info('a message') } + + expect(otel_logger.emitted).to be_empty + end + it 'drops the record (rather than sending it unscrubbed) when the callback raises' do before_send = proc { |_record| raise 'scrubber bug' } appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) From 6f338b7063a768444120118e537770329d3ba70c Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:07:33 -0400 Subject: [PATCH 18/30] fix(rails): coerce numeric strings for logs rate cap instead of silently disabling it --- posthog-rails/README.md | 4 +- posthog-rails/examples/posthog.rb | 3 +- .../generators/posthog/templates/posthog.rb | 3 +- .../lib/posthog/rails/configuration.rb | 11 ++++-- posthog-rails/lib/posthog/rails/logs/setup.rb | 22 +++++++++-- spec/posthog/rails/logs/setup_spec.rb | 39 +++++++++++++++++++ 6 files changed, 72 insertions(+), 10 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 6fadeb6..a42f4b4 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -37,8 +37,8 @@ no-ops, so it is safe to enable conditionally. Forwarding is capped at 6,000 records per minute by default to protect your ingestion quota from runaway log volume; when the cap trips, one warning record is emitted and further records are dropped for the remainder of the window. -Tune or disable it with `config.logs_max_records_per_minute` (set to `nil` to -disable). +Tune or disable it with `config.logs_max_records_per_minute` (set to `nil` or +`0` to disable; numeric strings such as ENV values are coerced). To scrub PII (or drop records entirely) before they leave the app, set `config.logs_before_send` to a proc that receives each record hash and returns diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 006f6d6..7659bc2 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -65,7 +65,8 @@ # config.logs_level = :info # Maximum records forwarded per minute, protecting your ingestion quota from - # runaway log volume (default: 6000; set to nil to disable the cap) + # runaway log volume. Numeric strings (e.g. from ENV) are coerced. + # (default: 6000; set to nil or 0 to disable the cap) # config.logs_max_records_per_minute = 6_000 # Modify or drop log records before they are sent, e.g. to scrub PII. diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 7ad56a3..61c667d 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -65,7 +65,8 @@ # config.logs_level = :info # Maximum records forwarded per minute, protecting your ingestion quota from - # runaway log volume (default: 6000; set to nil to disable the cap) + # runaway log volume. Numeric strings (e.g. from ENV) are coerced. + # (default: 6000; set to nil or 0 to disable the cap) # config.logs_max_records_per_minute = 6_000 # Modify or drop log records before they are sent, e.g. to scrub PII. diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index bc30d4f..6237bcc 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -7,6 +7,9 @@ module PostHog module Rails class Configuration + # Default cap on log records forwarded to PostHog Logs per minute. + DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE = 6_000 + # @return [Boolean] Whether to automatically capture exceptions from Rails. Defaults to false. attr_accessor :auto_capture_exceptions @@ -44,8 +47,10 @@ class Configuration # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info). attr_accessor :logs_level - # @return [Integer, nil] Maximum log records forwarded to PostHog Logs per minute, protecting the - # ingestion quota from runaway log volume. Defaults to 6000. Set to nil to disable the cap. + # @return [Integer, String, nil] Maximum log records forwarded to PostHog Logs per minute, protecting + # the ingestion quota from runaway log volume. Defaults to 6000. Numeric strings (e.g. from ENV) are + # coerced. Set to nil, 0, or a negative value to disable the cap; an unparseable value falls back to + # the default with a warning. attr_accessor :logs_max_records_per_minute # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity, :body, @@ -70,7 +75,7 @@ def initialize @logs_enabled = false @forward_rails_logger = true @logs_level = nil - @logs_max_records_per_minute = 6_000 + @logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE @logs_before_send = nil @logs_resource_attributes = {} end diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 6711394..22db0be 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -2,6 +2,7 @@ require 'logger' require 'posthog/logging' +require 'posthog/rails/configuration' require 'posthog/rails/logs/appender' require 'posthog/rails/logs/rate_limiter' @@ -121,11 +122,26 @@ def resolve_host 'https://us.i.posthog.com' end + # nil, 0, and negative values intentionally disable the cap. Numeric + # strings (e.g. from ENV) are coerced — deliberately via Integer() + # rather than to_i, since "abc".to_i == 0 would silently disable the + # cap. Unparseable values warn and fall back to the default cap: + # a misconfiguration should not switch the protection off. def build_rate_limiter(config) - limit = config.logs_max_records_per_minute - return nil unless limit.is_a?(Numeric) && limit.positive? + raw = config.logs_max_records_per_minute + return nil if raw.nil? + + limit = Integer(raw, exception: false) + if limit.nil? + logger.warn( + "logs_max_records_per_minute=#{raw.inspect} is not a number; using the default cap " \ + "of #{Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE} records/minute" + ) + limit = Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE + end + return nil unless limit.positive? - RateLimiter.new(limit.to_i) + RateLimiter.new(limit) end def require_otel_gems diff --git a/spec/posthog/rails/logs/setup_spec.rb b/spec/posthog/rails/logs/setup_spec.rb index 3a5fa0b..d24bc0c 100644 --- a/spec/posthog/rails/logs/setup_spec.rb +++ b/spec/posthog/rails/logs/setup_spec.rb @@ -124,4 +124,43 @@ end end end + + describe '.build_rate_limiter' do + let(:config) { PostHog::Rails.config } + + def build + described_class.send(:build_rate_limiter, config) + end + + it 'builds a limiter with the default cap' do + expect(build.limit).to eq(PostHog::Rails::Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE) + end + + it 'coerces numeric strings (e.g. from ENV)' do + config.logs_max_records_per_minute = '3000' + + expect(build.limit).to eq(3000) + end + + it 'returns nil for nil, zero, and negative values without warning' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + + [nil, 0, -1, '0'].each do |value| + config.logs_max_records_per_minute = value + expect(build).to be_nil + end + + expect(logger).not_to have_received(:warn) + end + + it 'warns and falls back to the default cap for unparseable values' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + config.logs_max_records_per_minute = 'lots' + + expect(build.limit).to eq(PostHog::Rails::Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE) + expect(logger).to have_received(:warn).with(/"lots" is not a number/) + end + end end From be99a7ccd57b1efb7439311be259bbc896ac612f Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:13:17 -0400 Subject: [PATCH 19/30] docs(rails): recommend require: false for the optional OpenTelemetry gems --- posthog-rails/README.md | 11 ++++++++--- posthog-rails/examples/posthog.rb | 9 +++++---- .../lib/generators/posthog/install_generator.rb | 6 +++--- .../lib/generators/posthog/templates/posthog.rb | 9 +++++---- posthog-rails/lib/posthog/rails/logs/setup.rb | 3 ++- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index a42f4b4..80f1a3a 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -18,11 +18,16 @@ This is opt-in and relies on the standard OpenTelemetry gems (Ruby 3.3+), which are not bundled. Add them to your `Gemfile`: ```ruby -gem 'opentelemetry-sdk' -gem 'opentelemetry-logs-sdk' -gem 'opentelemetry-exporter-otlp-logs' +gem 'opentelemetry-sdk', require: false +gem 'opentelemetry-logs-sdk', require: false +gem 'opentelemetry-exporter-otlp-logs', require: false ``` +`require: false` keeps the gems off the boot path — `posthog-rails` requires +them only when logs are enabled. It also avoids `opentelemetry-logs-sdk`'s +load-time `Configurator` patch, which would otherwise piggyback a second logs +pipeline onto an existing `OpenTelemetry::SDK.configure` (tracing) call. + Then enable it in `config/initializers/posthog.rb`: ```ruby diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 7659bc2..73da340 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -50,10 +50,11 @@ # Forward Rails.logger output to PostHog Logs over OTLP, automatically # correlated with the request's distinct_id and session_id. # - # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile: - # gem 'opentelemetry-sdk' - # gem 'opentelemetry-logs-sdk' - # gem 'opentelemetry-exporter-otlp-logs' + # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile. Use + # require: false — posthog-rails loads them only when logs are enabled: + # gem 'opentelemetry-sdk', require: false + # gem 'opentelemetry-logs-sdk', require: false + # gem 'opentelemetry-exporter-otlp-logs', require: false # # Enable log forwarding (default: false) # config.logs_enabled = true diff --git a/posthog-rails/lib/generators/posthog/install_generator.rb b/posthog-rails/lib/generators/posthog/install_generator.rb index 6947d06..5cef2f8 100644 --- a/posthog-rails/lib/generators/posthog/install_generator.rb +++ b/posthog-rails/lib/generators/posthog/install_generator.rb @@ -25,9 +25,9 @@ def show_readme say '' say 'Optional: forward Rails.logger to PostHog Logs', :yellow say ' - Add to your Gemfile (requires Ruby 3.3+):' - say " gem 'opentelemetry-sdk'" - say " gem 'opentelemetry-logs-sdk'" - say " gem 'opentelemetry-exporter-otlp-logs'" + say " gem 'opentelemetry-sdk', require: false" + say " gem 'opentelemetry-logs-sdk', require: false" + say " gem 'opentelemetry-exporter-otlp-logs', require: false" say ' - Set config.logs_enabled = true in the initializer' say ' - Docs: https://posthog.com/docs/logs' say '' diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 61c667d..1cd7bc0 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -50,10 +50,11 @@ # Forward Rails.logger output to PostHog Logs over OTLP, automatically # correlated with the request's distinct_id and session_id. # - # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile: - # gem 'opentelemetry-sdk' - # gem 'opentelemetry-logs-sdk' - # gem 'opentelemetry-exporter-otlp-logs' + # Requires the OpenTelemetry gems (Ruby 3.3+) in your Gemfile. Use + # require: false — posthog-rails loads them only when logs are enabled: + # gem 'opentelemetry-sdk', require: false + # gem 'opentelemetry-logs-sdk', require: false + # gem 'opentelemetry-exporter-otlp-logs', require: false # # Enable log forwarding (default: false) # config.logs_enabled = true diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 22db0be..3be1097 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -153,7 +153,8 @@ def require_otel_gems warn_once( "PostHog Logs enabled but the OpenTelemetry gems are missing (#{e.message}). " \ "Add 'opentelemetry-sdk', 'opentelemetry-logs-sdk', and " \ - "'opentelemetry-exporter-otlp-logs' to your Gemfile to enable log forwarding." + "'opentelemetry-exporter-otlp-logs' (each with require: false) to your Gemfile " \ + 'to enable log forwarding.' ) false end From 059d7128bdc9b3080d1387f1e81ededdfe8dec90 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:17:15 -0400 Subject: [PATCH 20/30] fix(rails): warn when logs are enabled but PostHog.init never ran --- posthog-rails/lib/posthog/rails/railtie.rb | 10 +++++++++- spec/posthog/rails/railtie_spec.rb | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index a31e583..af65d71 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -161,7 +161,15 @@ def insert_middleware_before(app, target, middleware) # @api private # @return [void] def self.install_posthog_logs - return unless PostHog.initialized? + unless PostHog.initialized? + # logs_enabled is an explicit opt-in, so leave a breadcrumb instead + # of silently skipping when PostHog.init never ran. + PostHog::Logging.logger.warn( + 'PostHog Logs is enabled but PostHog.init has not been called; ' \ + 'skipping log forwarding. Call PostHog.init in your initializer.' + ) + return + end appender = PostHog::Rails::Logs::Setup.install! return if appender.nil? diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb index 35c796e..56cbac5 100644 --- a/spec/posthog/rails/railtie_spec.rb +++ b/spec/posthog/rails/railtie_spec.rb @@ -119,12 +119,15 @@ end describe '.install_posthog_logs' do - it 'no-ops when PostHog is not initialized' do + it 'skips with a warning when PostHog is not initialized' do allow(PostHog::Rails::Logs::Setup).to receive(:install!) + logger = instance_spy(Logger) + PostHog::Logging.logger = logger PostHog::Rails::Railtie.install_posthog_logs expect(PostHog::Rails::Logs::Setup).not_to have_received(:install!) + expect(logger).to have_received(:warn).with(/PostHog Logs is enabled but PostHog\.init has not been called/) end it 'broadcasts Rails.logger when an appender is built' do From 100bdf53c3b072d2f1ffa242b83c867b72c38728 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:20:29 -0400 Subject: [PATCH 21/30] fix(rails): bound the at_exit logs flush with a 2s timeout so it can't starve the events flush --- posthog-rails/lib/posthog/rails/logs/setup.rb | 16 ++++++++++++---- spec/posthog/rails/logs/setup_spec.rb | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 3be1097..b07028b 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -17,6 +17,12 @@ module Logs # # @api private module Setup + # Bounds the at_exit flush. Without a timeout, the batch processor + # joins its worker thread unbounded and the exporter retries each + # batch with backoff — during an outage that can eat the whole + # SIGTERM grace period and starve the events client of its flush. + SHUTDOWN_TIMEOUT_SECONDS = 2 + class << self # @return [OpenTelemetry::SDK::Logs::LoggerProvider, nil] attr_reader :provider @@ -62,18 +68,20 @@ def install! # Flush any buffered log records. # + # @param timeout [Numeric, nil] Optional max seconds to spend flushing. # @return [void] - def force_flush - @provider&.force_flush + def force_flush(timeout: nil) + @provider&.force_flush(timeout: timeout) rescue StandardError => e logger.warn("Error flushing PostHog Logs: #{e.message}") end # Shut the pipeline down, flushing buffered records. # + # @param timeout [Numeric] Max seconds to spend; see {SHUTDOWN_TIMEOUT_SECONDS}. # @return [void] - def shutdown! - @provider&.shutdown + def shutdown!(timeout: SHUTDOWN_TIMEOUT_SECONDS) + @provider&.shutdown(timeout: timeout) rescue StandardError => e logger.warn("Error shutting down PostHog Logs: #{e.message}") end diff --git a/spec/posthog/rails/logs/setup_spec.rb b/spec/posthog/rails/logs/setup_spec.rb index d24bc0c..18a02a9 100644 --- a/spec/posthog/rails/logs/setup_spec.rb +++ b/spec/posthog/rails/logs/setup_spec.rb @@ -125,6 +125,16 @@ end end + describe '.shutdown!' do + it 'bounds the final flush with a timeout so a hung exporter cannot eat the SIGTERM grace period' do + provider = double('provider') + described_class.instance_variable_set(:@provider, provider) + expect(provider).to receive(:shutdown).with(timeout: described_class::SHUTDOWN_TIMEOUT_SECONDS) + + described_class.shutdown! + end + end + describe '.build_rate_limiter' do let(:config) { PostHog::Rails.config } From a148692e9b88390e5267362be46b8015ce7cf865 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:22:45 -0400 Subject: [PATCH 22/30] refactor(rails): rename forward_rails_logger to logs_forward_rails_logger for prefix consistency --- posthog-rails/examples/posthog.rb | 2 +- posthog-rails/lib/generators/posthog/templates/posthog.rb | 2 +- posthog-rails/lib/posthog/rails/configuration.rb | 4 ++-- posthog-rails/lib/posthog/rails/railtie.rb | 2 +- spec/posthog/rails/configuration_spec.rb | 2 +- spec/posthog/rails/railtie_spec.rb | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 73da340..45adccd 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -60,7 +60,7 @@ # config.logs_enabled = true # Broadcast Rails.logger into PostHog Logs (default: true when logs enabled) - # config.forward_rails_logger = true + # config.logs_forward_rails_logger = true # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) # config.logs_level = :info diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 1cd7bc0..a82bb2d 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -60,7 +60,7 @@ # config.logs_enabled = true # Broadcast Rails.logger into PostHog Logs (default: true when logs enabled) - # config.forward_rails_logger = true + # config.logs_forward_rails_logger = true # Minimum severity to forward; nil inherits Rails.logger's level (default: nil) # config.logs_level = :info diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 6237bcc..4601ee0 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -41,7 +41,7 @@ class Configuration # @return [Boolean] Whether to broadcast Rails.logger output into the PostHog Logs sink. Defaults to true # (only takes effect when {#logs_enabled} is true). - attr_accessor :forward_rails_logger + attr_accessor :logs_forward_rails_logger # @return [Integer, Symbol, nil] Minimum severity to forward to PostHog Logs. When nil, inherits the # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info). @@ -73,7 +73,7 @@ def initialize @current_user_method = :current_user @user_id_method = nil @logs_enabled = false - @forward_rails_logger = true + @logs_forward_rails_logger = true @logs_level = nil @logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE @logs_before_send = nil diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index af65d71..57902c2 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -174,7 +174,7 @@ def self.install_posthog_logs appender = PostHog::Rails::Logs::Setup.install! return if appender.nil? - broadcast_rails_logger(appender) if PostHog::Rails.config&.forward_rails_logger + broadcast_rails_logger(appender) if PostHog::Rails.config&.logs_forward_rails_logger rescue StandardError => e PostHog::Logging.logger.warn("Failed to set up PostHog Logs: #{e.message}") end diff --git a/spec/posthog/rails/configuration_spec.rb b/spec/posthog/rails/configuration_spec.rb index b58e39d..5412b89 100644 --- a/spec/posthog/rails/configuration_spec.rb +++ b/spec/posthog/rails/configuration_spec.rb @@ -43,7 +43,7 @@ describe 'PostHog Logs defaults' do it 'defaults logs to disabled with forwarding ready' do expect(config.logs_enabled).to be false - expect(config.forward_rails_logger).to be true + expect(config.logs_forward_rails_logger).to be true expect(config.logs_level).to be_nil expect(config.logs_max_records_per_minute).to eq(6_000) expect(config.logs_before_send).to be_nil diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb index 56cbac5..8b4e28f 100644 --- a/spec/posthog/rails/railtie_spec.rb +++ b/spec/posthog/rails/railtie_spec.rb @@ -141,9 +141,9 @@ expect(PostHog::Rails::Railtie).to have_received(:broadcast_rails_logger).with(appender) end - it 'does not broadcast when forward_rails_logger is disabled' do + it 'does not broadcast when logs_forward_rails_logger is disabled' do PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true) - PostHog::Rails.config.forward_rails_logger = false + PostHog::Rails.config.logs_forward_rails_logger = false allow(PostHog::Rails::Logs::Setup).to receive(:install!) .and_return(instance_double(PostHog::Rails::Logs::Appender)) allow(PostHog::Rails::Railtie).to receive(:broadcast_rails_logger) From 84bfd617e3ef1a6de0f059a27c71336c652d39e1 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:26:39 -0400 Subject: [PATCH 23/30] Remove logs_resource_attributes config; resource attrs are now fixed service metadata --- posthog-rails/examples/posthog.rb | 3 --- .../generators/posthog/templates/posthog.rb | 3 --- .../lib/posthog/rails/configuration.rb | 4 ---- posthog-rails/lib/posthog/rails/logs/setup.rb | 18 ++++++------------ spec/posthog/rails/configuration_spec.rb | 1 - 5 files changed, 6 insertions(+), 23 deletions(-) diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 45adccd..aab7eff 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -83,9 +83,6 @@ # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. - - # Extra OpenTelemetry resource attributes merged with service metadata - # config.logs_resource_attributes = { 'service.namespace' => 'my-team' } end # You can also configure Rails options directly: diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index a82bb2d..3a36c7a 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -83,9 +83,6 @@ # Logs reuse the same project token (api_key) and host configured below, so # there is nothing extra to set. Logs are sent to /i/v1/logs. - - # Extra OpenTelemetry resource attributes merged with service metadata - # config.logs_resource_attributes = { 'service.namespace' => 'my-team' } end # You can also configure Rails options directly: diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 4601ee0..e4e6dc2 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -59,9 +59,6 @@ class Configuration # PII. If the callback raises, the record is dropped. Defaults to nil. attr_accessor :logs_before_send - # @return [Hash] Extra OpenTelemetry resource attributes merged with auto-detected service metadata. - attr_accessor :logs_resource_attributes - # @return [PostHog::Rails::Configuration] def initialize @auto_capture_exceptions = false @@ -77,7 +74,6 @@ def initialize @logs_level = nil @logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE @logs_before_send = nil - @logs_resource_attributes = {} end # Default exceptions that Rails apps typically don't want to track. diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index b07028b..32fff3e 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -52,7 +52,7 @@ def install! return nil end - @provider = build_provider(config, token) + @provider = build_provider(token) otel_logger = @provider.logger(name: 'posthog-rails', version: PostHog::VERSION) level = resolve_level(config.logs_level) || rails_logger_level @appender = Appender.new( @@ -167,8 +167,8 @@ def require_otel_gems false end - def build_provider(config, token) - resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attributes(config)) + def build_provider(token) + resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attributes) provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource) exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( endpoint: logs_endpoint(resolve_host), @@ -179,17 +179,15 @@ def build_provider(config, token) provider end - def resource_attributes(config) + def resource_attributes # service.version is intentionally omitted. Per OpenTelemetry semantic # conventions it is the deployed application's version, not this gem's. # The posthog-rails name/version travel with each record via the - # instrumentation scope (see LoggerProvider#logger above). Users can - # still set service.version through logs_resource_attributes. - attrs = { + # instrumentation scope (see LoggerProvider#logger above). + { 'service.name' => service_name, 'deployment.environment' => ::Rails.env.to_s } - attrs.merge(stringify_keys(config.logs_resource_attributes || {})) end def service_name @@ -229,10 +227,6 @@ def normalize(value) stripped.empty? ? nil : stripped end - def stringify_keys(hash) - hash.transform_keys(&:to_s) - end - def warn_once(message) return if @warned diff --git a/spec/posthog/rails/configuration_spec.rb b/spec/posthog/rails/configuration_spec.rb index 5412b89..af24454 100644 --- a/spec/posthog/rails/configuration_spec.rb +++ b/spec/posthog/rails/configuration_spec.rb @@ -47,7 +47,6 @@ expect(config.logs_level).to be_nil expect(config.logs_max_records_per_minute).to eq(6_000) expect(config.logs_before_send).to be_nil - expect(config.logs_resource_attributes).to eq({}) end end end From 9bcd5ad7d6795f0c9e83afef4c94d8c628ceb653 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:30:11 -0400 Subject: [PATCH 24/30] Remove unused Setup.force_flush; shutdown! already flushes buffered records --- posthog-rails/lib/posthog/rails/logs/setup.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index 32fff3e..ca7b3ae 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -66,16 +66,6 @@ def install! nil end - # Flush any buffered log records. - # - # @param timeout [Numeric, nil] Optional max seconds to spend flushing. - # @return [void] - def force_flush(timeout: nil) - @provider&.force_flush(timeout: timeout) - rescue StandardError => e - logger.warn("Error flushing PostHog Logs: #{e.message}") - end - # Shut the pipeline down, flushing buffered records. # # @param timeout [Numeric] Max seconds to spend; see {SHUTDOWN_TIMEOUT_SECONDS}. From e7f1f833aebe0f9906702081330d214bb5f99d76 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:33:23 -0400 Subject: [PATCH 25/30] Warn once on invalid logs_level instead of silently falling back to the Rails logger level --- posthog-rails/lib/posthog/rails/logs/setup.rb | 4 +++ spec/posthog/rails/logs/setup_spec.rb | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/posthog-rails/lib/posthog/rails/logs/setup.rb b/posthog-rails/lib/posthog/rails/logs/setup.rb index ca7b3ae..30272f6 100644 --- a/posthog-rails/lib/posthog/rails/logs/setup.rb +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -201,6 +201,10 @@ def resolve_level(level) ::Logger.const_get(level.to_s.upcase) rescue NameError + warn_once( + "Invalid logs_level #{level.inspect}; expected one of :debug, :info, :warn, " \ + ':error, :fatal, :unknown (or an Integer). Falling back to the Rails logger level.' + ) nil end diff --git a/spec/posthog/rails/logs/setup_spec.rb b/spec/posthog/rails/logs/setup_spec.rb index 18a02a9..04b8643 100644 --- a/spec/posthog/rails/logs/setup_spec.rb +++ b/spec/posthog/rails/logs/setup_spec.rb @@ -173,4 +173,30 @@ def build expect(logger).to have_received(:warn).with(/"lots" is not a number/) end end + + describe '.resolve_level' do + def resolve(level) + described_class.send(:resolve_level, level) + end + + it 'resolves valid symbols, strings, and integers without warning' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + + expect(resolve(:warn)).to eq(Logger::WARN) + expect(resolve('error')).to eq(Logger::ERROR) + expect(resolve(Logger::INFO)).to eq(Logger::INFO) + expect(resolve(nil)).to be_nil + + expect(logger).not_to have_received(:warn) + end + + it 'warns naming the bad value and falls back to nil for unknown levels' do + logger = instance_spy(Logger) + PostHog::Logging.logger = logger + + expect(resolve(:warning)).to be_nil + expect(logger).to have_received(:warn).with(/Invalid logs_level :warning.*:debug, :info, :warn/) + end + end end From b5267ac5eb1654093931d9613de0a904de381cb5 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:42:05 -0400 Subject: [PATCH 26/30] Keep forwarding threshold out of Logger#level so BroadcastLogger predicates and level= are unaffected; document silence/tagged gaps --- posthog-rails/README.md | 15 +++++++++ .../lib/posthog/rails/logs/appender.rb | 14 +++++++-- spec/posthog/rails/logs/appender_spec.rb | 31 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 80f1a3a..0d0e331 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -53,3 +53,18 @@ record is dropped. If your app already uses OpenTelemetry tracing, log records emitted during a traced request automatically carry the active `trace_id`/`span_id` — no configuration needed. + +`config.logs_level` filters what is forwarded to PostHog; it never changes +what your app logs. Setting it below the Rails logger level (e.g. `:debug` +with an `:info` app) does not make Rails or ActiveRecord generate extra +output — only records the app actually produces are forwarded. + +Known limitations of the broadcast approach: + +- `Rails.logger.silence` does not silence forwarding — silenced records still + ship to PostHog (the silencer only lowers the level of loggers that support + `local_level`). +- `Rails.logger.tagged` tags (including `config.log_tags` request IDs) are not + attached to forwarded records, and the non-block form + (`Rails.logger.tagged('X')`) returns a logger that bypasses forwarding + entirely. diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 2319c4d..1c5e277 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -54,7 +54,17 @@ def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil) @otel_logger = otel_logger @rate_limiter = rate_limiter @before_send = before_send - self.level = level unless level.nil? + # The forwarding threshold deliberately does NOT live in Logger#level. + # Rails 7.1+ BroadcastLogger computes #level as the min and #debug? + # etc. as the any? across sinks, so storing it there would widen the + # app-wide predicates (logs_level = :debug would flip + # Rails.logger.debug? true and make e.g. ActiveRecord start + # generating SQL debug lines), and a broadcast-wide + # `Rails.logger.level =` would clobber the configured logs_level. + # Pinning the inherited level to UNKNOWN keeps this sink invisible + # to those calculations; filtering happens against @threshold in #add. + @threshold = level || ::Logger::DEBUG + self.level = ::Logger::UNKNOWN end # Mirrors `Logger#add` message/progname resolution, then emits to OTel @@ -63,7 +73,7 @@ def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil) # @return [Boolean] Always true so it composes with broadcast loggers. def add(severity, message = nil, progname = nil) severity ||= ::Logger::UNKNOWN - return true if severity < level + return true if severity < @threshold if message.nil? if block_given? diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index de08af2..3df4896 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -6,6 +6,8 @@ require 'posthog/rails/logs/appender' require 'posthog/rails/logs/rate_limiter' +require 'active_support' +require 'active_support/broadcast_logger' RSpec.describe PostHog::Rails::Logs::Appender do let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } @@ -297,6 +299,35 @@ def on_emit(**kwargs) end end + # Rails 7.1+ BroadcastLogger computes #level as the min and #debug? etc. as + # the any? across sinks, so the appender must keep its forwarding threshold + # out of Logger#level or it changes app-wide logging behavior. + describe 'broadcast level isolation' do + let(:file_logger) { Logger.new(IO::NULL, level: Logger::INFO) } + + it 'does not widen BroadcastLogger#debug?/#level even when forwarding at :debug' do + debug_appender = described_class.new(otel_logger, level: Logger::DEBUG) + broadcast = ActiveSupport::BroadcastLogger.new(file_logger, debug_appender) + + expect(broadcast.debug?).to be(false) + expect(broadcast.level).to eq(Logger::INFO) + + # The threshold still applies to what the appender forwards. + broadcast.debug('sql query') + broadcast.info('request served') + expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['sql query', 'request served']) + end + + it 'keeps the configured threshold when a broadcast-wide level= is dispatched' do + broadcast = ActiveSupport::BroadcastLogger.new(file_logger, appender) + + broadcast.level = Logger::ERROR + + broadcast.info('still wanted in posthog') + expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['still wanted in posthog']) + end + end + describe 'context correlation' do it 'stamps the request distinct_id, session_id, and request metadata' do context_class.with_context( From 46874276dff86d660d41b94d416823490bf19910 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 21:47:36 -0400 Subject: [PATCH 27/30] Guard Appender#add against re-entrant logging and warn once when emit fails persistently --- .../lib/posthog/rails/logs/appender.rb | 81 +++++++++++++------ spec/posthog/rails/logs/appender_spec.rb | 21 ++++- 2 files changed, 75 insertions(+), 27 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/appender.rb b/posthog-rails/lib/posthog/rails/logs/appender.rb index 1c5e277..35c25d8 100644 --- a/posthog-rails/lib/posthog/rails/logs/appender.rb +++ b/posthog-rails/lib/posthog/rails/logs/appender.rb @@ -67,42 +67,60 @@ def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil) self.level = ::Logger::UNKNOWN end + # Re-entrancy guard key. Fiber-local (Thread.current[]), which is what + # recursion needs: if anything inside #add logs through a broadcast + # that includes this appender (e.g. a logs_before_send callback calling + # Rails.logger), the nested call would recurse until SystemStackError — + # which, as an Exception, escapes the rescue below and breaks the app. + REENTRANCY_KEY = :posthog_rails_logs_emitting + # Mirrors `Logger#add` message/progname resolution, then emits to OTel # instead of writing to a log device. # # @return [Boolean] Always true so it composes with broadcast loggers. def add(severity, message = nil, progname = nil) - severity ||= ::Logger::UNKNOWN - return true if severity < @threshold - - if message.nil? - if block_given? - message = yield - else - message = progname - progname = nil + return true if Thread.current[REENTRANCY_KEY] + + begin + Thread.current[REENTRANCY_KEY] = true + + severity ||= ::Logger::UNKNOWN + return true if severity < @threshold + + if message.nil? + if block_given? + message = yield + else + message = progname + progname = nil + end end - end - return true if message.nil? - return true if self_log?(message, progname) + return true if message.nil? + return true if self_log?(message, progname) - record = apply_before_send(build_record(severity, message, progname)) - return true if record.nil? + record = apply_before_send(build_record(severity, message, progname)) + return true if record.nil? - case @rate_limiter&.record - when :reject - return true - when :reject_first - emit_rate_cap_notice - return true - end + case @rate_limiter&.record + when :reject + return true + when :reject_first + emit_rate_cap_notice + return true + end - emit(record) - true - rescue StandardError - # Never let log forwarding break the calling code path. - true + emit(record) + true + rescue StandardError => e + # Never let log forwarding break the calling code path, but leave + # one breadcrumb: a persistent emit failure would otherwise drop + # 100% of records with no signal anywhere. + warn_emit_error(e) + true + ensure + Thread.current[REENTRANCY_KEY] = nil + end end private @@ -175,6 +193,17 @@ def warn_before_send(description) PostHog::Logging.logger.warn("logs_before_send #{description}; dropping the record") end + def warn_emit_error(error) + # Benign race: concurrent first failures may warn more than once. + return if @emit_error_warned + + @emit_error_warned = true + PostHog::Logging.logger.warn( + "PostHog Logs failed to emit a record (#{error.class}: #{error.message}); " \ + 'further failures will be dropped silently' + ) + end + def body_for(message) str = message.is_a?(String) ? message.dup : message.inspect str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8 diff --git a/spec/posthog/rails/logs/appender_spec.rb b/spec/posthog/rails/logs/appender_spec.rb index 3df4896..65681cf 100644 --- a/spec/posthog/rails/logs/appender_spec.rb +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -102,8 +102,10 @@ def on_emit(**kwargs) expect(otel_logger.emitted).to be_empty end - it 'never raises even if the otel logger blows up' do + it 'never raises even if the otel logger blows up, warning once so the failure is debuggable' do allow(otel_logger).to receive(:on_emit).and_raise(StandardError, 'export failed') + expect(PostHog::Logging.logger) + .to receive(:warn).with(/failed to emit a record \(StandardError: export failed\)/).once expect { appender.info('hello') }.not_to raise_error expect(appender.info('hello')).to be(true) @@ -299,6 +301,23 @@ def on_emit(**kwargs) end end + describe 're-entrancy' do + # Without the guard this recurses until SystemStackError, which escapes + # the rescue StandardError in #add and breaks the request. + it 'drops nested records when a before_send callback logs through a broadcast including the appender' do + broadcast = nil + before_send = proc do |record| + broadcast.info('nested log from callback') + record + end + appender = described_class.new(otel_logger, level: Logger::INFO, before_send: before_send) + broadcast = ActiveSupport::BroadcastLogger.new(Logger.new(IO::NULL), appender) + + expect { broadcast.info('outer') }.not_to raise_error + expect(otel_logger.emitted.map { |r| r[:body] }).to eq(['outer']) + end + end + # Rails 7.1+ BroadcastLogger computes #level as the min and #debug? etc. as # the any? across sinks, so the appender must keep its forwarding threshold # out of Logger#level or it changes app-wide logging behavior. From 9f527a3f3adacaf5e8bb09b98e6d432d0244caca Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 22:08:48 -0400 Subject: [PATCH 28/30] Add fork-safety spec pinning BatchLogRecordProcessor's post-fork restart through the logs pipeline --- Gemfile | 3 + Gemfile.lock | 27 +++++++ spec/posthog/rails/logs/fork_safety_spec.rb | 79 +++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 spec/posthog/rails/logs/fork_safety_spec.rb diff --git a/Gemfile b/Gemfile index 4aa32b8..37e8ac5 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ group :development, :test do gem 'activesupport', '~> 7.1' gem 'commander', '~> 5.0' gem 'oj', '~> 3.16.10' + # Soft dependency of posthog-rails' PostHog Logs feature; present here only + # so the fork-safety spec can exercise the real BatchLogRecordProcessor. + gem 'opentelemetry-logs-sdk', require: false gem 'prettier' gem 'railties', '~> 7.1' gem 'rake', '~> 13.2.1' diff --git a/Gemfile.lock b/Gemfile.lock index 672a406..b573f47 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,6 +100,25 @@ GEM oj (3.16.16) bigdecimal (>= 3.0) ostruct (>= 0.2) + opentelemetry-api (1.8.0) + logger + opentelemetry-common (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-logs-api (0.2.0) + opentelemetry-api (~> 1.0) + opentelemetry-logs-sdk (0.4.0) + opentelemetry-api (~> 1.2) + opentelemetry-logs-api (~> 0.1) + opentelemetry-sdk (~> 1.3) + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.10.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.36.0) + opentelemetry-api (~> 1.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.11.1) @@ -231,6 +250,7 @@ DEPENDENCIES concurrent-ruby irb oj (~> 3.16.10) + opentelemetry-logs-sdk posthog-ruby! prettier railties (~> 7.1) @@ -283,6 +303,13 @@ CHECKSUMS nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 oj (3.16.16) sha256=3635b36128991796434f55da8decc0de236a323535adcb36fc04e6d0253c013d + opentelemetry-api (1.8.0) sha256=3af51183daf0f56a164bc1579782245be70a40678566b9a393cbe5af28ea87c6 + opentelemetry-common (0.23.0) sha256=da721190479d57bae0ad2207468f47f3e2c3b9a91024b5bc32c9d280183eb32c + opentelemetry-logs-api (0.2.0) sha256=0e9241b9b53a315101a6a5f2efbfa80278a0d39f9220915871f8d6057c39b117 + opentelemetry-logs-sdk (0.4.0) sha256=860a5a3916054bf8b37a8a0b565d7ebabc37691a7b505eb0d21b734619fe8074 + opentelemetry-registry (0.4.0) sha256=903fa6bfaa29eac1c1d73a4fdd29b850977b5353b84b8cdff11222c00ad2968f + opentelemetry-sdk (1.10.0) sha256=43719949be8df24dcaeb86ebbf75636cda87d51a01af2729499b92a48b80521a + opentelemetry-semantic_conventions (1.36.0) sha256=c1b1607dbc7853aac7f9e23f6e8b76969c45b07f2b812a4aa4383c19a3b0f617 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 diff --git a/spec/posthog/rails/logs/fork_safety_spec.rb b/spec/posthog/rails/logs/fork_safety_spec.rb new file mode 100644 index 0000000..20945e1 --- /dev/null +++ b/spec/posthog/rails/logs/fork_safety_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +$LOAD_PATH.unshift File.expand_path('../../../../posthog-rails/lib', __dir__) + +require 'posthog/rails/logs/appender' + +otel_available = + begin + require 'opentelemetry-logs-sdk' + true + rescue LoadError + false + end + +# Preloading servers (Puma cluster mode with preload_app!, Unicorn) build the +# logs pipeline in the master via after_initialize, then fork workers — and +# threads do not survive fork, so the BatchLogRecordProcessor's worker thread +# is dead in every child. The pipeline relies on the processor's built-in +# pid-change detection to restart itself in forked workers; this spec pins +# that behavior so an OTel SDK regression (or a swap to a processor without +# fork detection) is caught in CI rather than as silently unflushed logs. +RSpec.describe 'PostHog Logs fork safety', if: otel_available && Process.respond_to?(:fork) do + # Exporter that writes each record body to a pipe, so exports happening + # inside the forked child are observable from the parent. + let(:exporter_class) do + Class.new do + def initialize(io) + @io = io + end + + def export(records, timeout: nil) # rubocop:disable Lint/UnusedMethodArgument + records.each { |record| @io.puts(record.body) } + @io.flush + OpenTelemetry::SDK::Logs::Export::SUCCESS + end + + def force_flush(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument + OpenTelemetry::SDK::Logs::Export::SUCCESS + end + + def shutdown(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument + OpenTelemetry::SDK::Logs::Export::SUCCESS + end + end + end + + it 'exports records logged in a forked worker (BatchLogRecordProcessor restarts post-fork)' do + reader, writer = IO.pipe + provider = OpenTelemetry::SDK::Logs::LoggerProvider.new + provider.add_log_record_processor( + OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter_class.new(writer)) + ) + appender = PostHog::Rails::Logs::Appender.new( + provider.logger(name: 'posthog-rails-test'), + level: Logger::INFO + ) + + # Emit pre-fork so the processor's worker thread starts in the parent — + # the preloaded-server scenario where that thread is dead in the child. + appender.info('from parent') + provider.force_flush + + pid = fork do + reader.close + appender.info('from child') + provider.force_flush + exit!(0) # skip at_exit/RSpec hooks inherited from the parent + end + writer.close + _, status = Process.wait2(pid) + + expect(status).to be_success + expect(reader.read).to include('from child') + ensure + reader&.close + end +end From 87b49fc8f7a8ea138f862a8bdfecb79f3c215ea3 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 11 Jun 2026 22:12:22 -0400 Subject: [PATCH 29/30] feat(rails): add changeset and fork-safety spec for PostHog Logs forwarding --- .changeset/rails-posthog-logs-forwarding.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rails-posthog-logs-forwarding.md diff --git a/.changeset/rails-posthog-logs-forwarding.md b/.changeset/rails-posthog-logs-forwarding.md new file mode 100644 index 0000000..2086d8c --- /dev/null +++ b/.changeset/rails-posthog-logs-forwarding.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +Add opt-in PostHog Logs support to posthog-rails: set `config.logs_enabled = true` to forward `Rails.logger` output to PostHog Logs over OpenTelemetry (OTLP), automatically correlated with the request's PostHog distinct ID and session ID (and active trace/span when OpenTelemetry tracing is present). Includes a configurable severity filter (`logs_level`), a rate cap (`logs_max_records_per_minute`, default 6,000/min), and a `logs_before_send` callback for scrubbing or dropping records. Relies on the optional OpenTelemetry gems; when they are absent the feature warns once and no-ops. From ce9ca0e75988d1043ab43ffcd43d15d6583d840e Mon Sep 17 00:00:00 2001 From: John Nagro Date: Fri, 12 Jun 2026 10:42:31 -0400 Subject: [PATCH 30/30] Remove unused Severity.for; name_for and for_name cover the split emit path --- posthog-rails/lib/posthog/rails/logs/severity.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/logs/severity.rb b/posthog-rails/lib/posthog/rails/logs/severity.rb index 46ac020..a66b529 100644 --- a/posthog-rails/lib/posthog/rails/logs/severity.rb +++ b/posthog-rails/lib/posthog/rails/logs/severity.rb @@ -14,12 +14,6 @@ module Logs module Severity module_function - # @param severity [Integer, nil] A Ruby `Logger` severity constant. - # @return [Array(Integer, String)] OpenTelemetry severity number and text. - def for(severity) - for_name(name_for(severity)) - end - # @param severity [Integer, nil] A Ruby `Logger` severity constant. # @return [Symbol] The severity name (:debug, :info, :warn, :error, :fatal). def name_for(severity)