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..1714c7c --- /dev/null +++ b/posthog-rails/lib/posthog/rails/logs/setup.rb @@ -0,0 +1,203 @@ +# 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) + # 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, + '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..401af13 --- /dev/null +++ b/spec/posthog/rails/logs/appender_spec.rb @@ -0,0 +1,123 @@ +# 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 + + # 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 + 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