feat(rails): forward Rails.logger to PostHog Logs via OpenTelemetry#167
feat(rails): forward Rails.logger to PostHog Logs via OpenTelemetry#167johnnagro wants to merge 8 commits into
Conversation
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.
Prompt To Fix All With AIFix 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 |
|
Cc @PostHog/logs |
| end | ||
|
|
||
| # @return [String, nil] The project API key this client was initialized with | ||
| # (after whitespace trimming). Nil when the client is disabled. |
There was a problem hiding this comment.
is there an alternative here that doesn't add to the public API? what about env/config instead?
There was a problem hiding this comment.
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.
| end | ||
|
|
||
| # @return [String, nil] The project API key this client was initialized with | ||
| # (after whitespace trimming). Nil when the client is disabled. |
There was a problem hiding this comment.
I think this is also stored raw, never trimmed, can you confirm?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
I think we can also add feature_flags
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
No explicit thread-safety here
There was a problem hiding this comment.
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.
| def self_log?(message, progname) | ||
| return true if progname.to_s == SELF_LOG_PROGNAME | ||
|
|
||
| message.is_a?(String) && message.include?(SELF_LOG_PREFIX) |
There was a problem hiding this comment.
I wonder if this should be start_with? instead of include?
| endpoint: logs_endpoint(resolve_host), | ||
| headers: { 'Authorization' => "Bearer #{token}" } | ||
| ) | ||
| processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter) |
There was a problem hiding this comment.
I think we want a rate cap otherwise we can burn through the quota
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
I think it would be good to have a beforeSend to scrub PII.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
|
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. |
💡 Motivation and Context
Adds an opt-in integration that forwards
Rails.loggeroutput 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 alwaystarget the same project as analytics. Disabled by default; enabled via
PostHog::Rails.config.logs_enabled.💚 How did you test it?
and railtie broadcast wiring (full suite green on Ruby 3.2.2, rubocop clean).
rails newapp against a real PostHog project:Rails.loggeroutput appears in PostHog Logs.📝 Checklist