Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions posthog-rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions posthog-rails/examples/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>/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:
Expand Down
8 changes: 8 additions & 0 deletions posthog-rails/lib/generators/posthog/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions posthog-rails/lib/generators/posthog/templates/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>/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:
Expand Down
3 changes: 3 additions & 0 deletions posthog-rails/lib/posthog/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions posthog-rails/lib/posthog/rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions posthog-rails/lib/posthog/rails/logs/appender.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading