Skip to content

feat(rails): forward Rails.logger to PostHog Logs via OpenTelemetry#167

Open
johnnagro wants to merge 8 commits into
PostHog:mainfrom
noreastergroup:feat/posthog-rails-otel-logs
Open

feat(rails): forward Rails.logger to PostHog Logs via OpenTelemetry#167
johnnagro wants to merge 8 commits into
PostHog:mainfrom
noreastergroup:feat/posthog-rails-otel-logs

Conversation

@johnnagro

@johnnagro johnnagro commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Adds an opt-in integration that forwards Rails.logger output to PostHog Logs
(https://posthog.com/docs/logs) over OpenTelemetry/OTLP, automatically correlated
with the request's distinct_id and session_id captured by the existing
request-context middleware.
The OpenTelemetry gems are optional/soft dependencies loaded lazily, so the
feature no-ops with a single warning when absent (and on Ruby < 3.3 where the
logs SDK is unsupported). The logs token and host are derived from the configured
client (config.api_key / config.host) with ENV fallbacks, so logs always
target the same project as analytics. Disabled by default; enabled via
PostHog::Rails.config.logs_enabled.

💚 How did you test it?

  • Unit specs for the appender, setup (gems present/absent + token/host resolution),
    and railtie broadcast wiring (full suite green on Ruby 3.2.2, rubocop clean).
  • End-to-end in an ephemeral rails new app against a real PostHog project:
    Rails.logger output appears in PostHog Logs.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.
Screen Shot 2026-06-04 at 7 24 04 PM

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.
@johnnagro johnnagro requested a review from a team as a code owner June 4, 2026 18:30
@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
posthog-rails/lib/posthog/rails/logs/setup.rb:136-140
The `service.version` OTel resource attribute is being set to `PostHog::VERSION`, which is the SDK/integration gem version — not the Rails application's version. Per OpenTelemetry semantic conventions, `service.version` represents the deployed application version, so this would cause every service to report the PostHog gem version as its "version" in PostHog Logs. Consider using `telemetry.sdk.version` for the SDK version instead, and omitting `service.version` from the defaults (users can supply it via `logs_resource_attributes`).

```suggestion
            attrs = {
              'service.name' => service_name,
              'telemetry.sdk.name' => 'posthog-rails',
              'telemetry.sdk.version' => PostHog::VERSION,
              'deployment.environment' => ::Rails.env.to_s
            }
```

### Issue 2 of 2
spec/posthog/rails/logs/appender_spec.rb:40-46
Non-parametric severity tests per project convention — `Severity::MAPPING` defines 6 entries (DEBUG→5, INFO→9, WARN→13, ERROR→17, FATAL→21, UNKNOWN→9) but only INFO and ERROR are verified. An RSpec `using` table (e.g., a `described_class` parametric approach with `let`/`subject` per row, or a shared example loop over `MAPPING`) would cover all cases with no duplication and catch a mapping regression for WARN, FATAL, or the UNKNOWN→INFO fallback.

Reviews (1): Last reviewed commit: "feat(rails): forward Rails.logger to Pos..." | Re-trigger Greptile

Comment thread posthog-rails/lib/posthog/rails/logs/setup.rb
Comment thread spec/posthog/rails/logs/appender_spec.rb Outdated
@turnipdabeets

Copy link
Copy Markdown
Contributor

Cc @PostHog/logs

Comment thread lib/posthog/client.rb Outdated
end

# @return [String, nil] The project API key this client was initialized with
# (after whitespace trimming). Nil when the client is disabled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there an alternative here that doesn't add to the public API? what about env/config instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good point. I've reworked it so the core client is untouched. PostHog.init (defined in posthog-rails) already sees the resolved options before constructing the client, so the logs setup now captures api_key/host there, with the existing ENV fallbacks for anyone constructing a client directly. The attr_readers on PostHog::Client will be reverted.

Comment thread lib/posthog/client.rb Outdated
end

# @return [String, nil] The project API key this client was initialized with
# (after whitespace trimming). Nil when the client is disabled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is also stored raw, never trimmed, can you confirm?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed it's trimmed. Initialize runs opts[:api_key] = normalize_string_option(opts[:api_key]) (which calls .strip) before @api_key = opts[:api_key], so the reader returns the normalized value. However, the doc wording is imprecise when it says "Nil when disabled" since a blank key disables the client but stores "". I'll fix the comment (or this becomes moot per your other comment).

class Appender < ::Logger
SELF_LOG_PREFIX = '[posthog-ruby]'
SELF_LOG_PROGNAME = 'PostHog'
REQUEST_ATTRIBUTE_KEYS = %w[$current_url $request_method $request_path].freeze

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can also add feature_flags

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really cool idea. One question first: the appender forwards attributes from the request context, and right now nothing populates feature flags into that context automatically. Flag evaluations stay in the returned snapshot and only attach to capture events when passed explicitly. For this to work i think we'd also need to wire flag evaluation into the request context.

Is that the desired state here, or a later addition?


def emit(severity, message, progname)
severity_number, severity_text = Severity.for(severity)
@otel_logger.on_emit(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No explicit thread-safety here

@johnnagro johnnagro Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this is correct. emit has no shared mutable state. Synchronization happens downstream in OTel's BatchLogRecordProcessor. I added a comment to clarify. Let me know if you disagree though - we need these things to be correct.

Comment thread posthog-rails/lib/posthog/rails/logs/setup.rb
def self_log?(message, progname)
return true if progname.to_s == SELF_LOG_PROGNAME

message.is_a?(String) && message.include?(SELF_LOG_PREFIX)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should be start_with? instead of include?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call.

Comment thread posthog-rails/lib/posthog/rails/logs/setup.rb
endpoint: logs_endpoint(resolve_host),
headers: { 'Authorization' => "Bearer #{token}" }
)
processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want a rate cap otherwise we can burn through the quota

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this was worth doing. I've added an opt-out rate cap. config.logs_max_records_per_minute (default 6,000, nil disables) is enforced in the appender via a fixed one-minute window (monotonic clock, mutex-guarded counter). The check runs after level filtering and self-log suppression, so only records that would actually ship count against the cap. When it trips, a single WARN record is emitted noting that further records are being dropped for the remainder of the window, so truncation is never silent.

This mirrors what the other PostHog SDKs with built-in logs support already do: posthog-react-native ships a tumbling-window rateCap (default 500/10s) and posthog-js a maxLogsPerInterval (default 1,000 per 3s flush window), both with a single warning on drop. The 6,000/min default sits between the two, happy to adjust the number if you have a preference.

# --------------------------------------------------------------------------
# POSTHOG LOGS (OpenTelemetry) - opt-in
# --------------------------------------------------------------------------
# Forward Rails.logger output to PostHog Logs over OTLP, automatically

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to have a beforeSend to scrub PII.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool idea. The RN SDK's logs config has exactly this (logs.beforeSend), and the core Ruby client already has before_send for events, so I added config.logs_before_send with matching semantics: the proc receives a record hash (:body, :severity, :attributes, :timestamp) and returns a modified hash to send or nil to drop. One deliberate difference from the events hook: if the callback raises, the record is dropped rather than sent unmodified. I thought this was the right way if we're thinking about a PII scrubber, failing closed seemed like the right default. It also runs after the rate-cap check, so a log flood doesn't pay scrubbing costs for records that would be dropped anyway.

# Opt-in: forward logs to PostHog Logs over OTLP
config.after_initialize do
install_posthog_logs if PostHog::Rails.config&.logs_enabled
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build_provider runs in after_initialize (pre-fork in preloaded servers), and install! is idempotent so workers can't rebuild it. Does BatchLogRecordProcessor self-restart its thread via Process._fork? If not, logs are captured but never flushed from forked workers — we'd need to build/reset the provider in an after-fork hook instead. Was the E2E run single-process or cluster mode?

@johnnagro johnnagro Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, great question - this is handled inside the OTel SDK. BatchLogRecordProcessor does its own fork detection (source). There's OTEL_RUBY_BLRP_START_THREAD_ON_BOOT=false for preload setups that want to skip the master's thread entirely.

The E2E run was single-process (dev-mode Puma), so it didn't exercise this path directly - happy to add a cluster-mode smoke test if you'd like.

@johnnagro

Copy link
Copy Markdown
Contributor Author

Thanks for taking the time to look this over and leave such thoughtful comments, I appreciate it @turnipdabeets @DanielVisca

I made some changes based on your feedback. There is one open question about feature flags.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants