|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module PostHog |
| 4 | + module Internal |
| 5 | + # Internal request/fiber-local context applied to capture calls. |
| 6 | + # Uses Rails' isolated execution state when available, otherwise falls back |
| 7 | + # to thread-local storage in the core SDK. |
| 8 | + # |
| 9 | + # This is intentionally not exposed as a public SDK API in Ruby yet. It exists |
| 10 | + # to let framework integrations such as posthog-rails propagate request-scoped |
| 11 | + # tracing headers to regular capture and exception events without making the |
| 12 | + # server-side SDK globally stateful per user. |
| 13 | + class Context |
| 14 | + STORAGE_KEY = :posthog_context |
| 15 | + |
| 16 | + attr_reader :distinct_id, :session_id, :properties |
| 17 | + |
| 18 | + def initialize(distinct_id: nil, session_id: nil, properties: {}) |
| 19 | + @distinct_id = distinct_id |
| 20 | + @session_id = session_id |
| 21 | + @properties = properties ? properties.dup : {} |
| 22 | + apply_session_property! |
| 23 | + end |
| 24 | + |
| 25 | + def self.current |
| 26 | + if defined?(ActiveSupport::IsolatedExecutionState) |
| 27 | + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] |
| 28 | + else |
| 29 | + Thread.current[STORAGE_KEY] |
| 30 | + end |
| 31 | + end |
| 32 | + |
| 33 | + def self.current=(context) |
| 34 | + if defined?(ActiveSupport::IsolatedExecutionState) |
| 35 | + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = context |
| 36 | + else |
| 37 | + Thread.current[STORAGE_KEY] = context |
| 38 | + end |
| 39 | + end |
| 40 | + |
| 41 | + def self.with_context(data = nil, fresh: false, **kwargs) |
| 42 | + previous_context = current |
| 43 | + raise ArgumentError, 'with_context requires a block' unless block_given? |
| 44 | + |
| 45 | + self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh) |
| 46 | + yield |
| 47 | + ensure |
| 48 | + self.current = previous_context |
| 49 | + end |
| 50 | + |
| 51 | + def self.resolve(data, parent, fresh: false) |
| 52 | + data = normalize_data(data) |
| 53 | + |
| 54 | + parent_properties = fresh || parent.nil? ? {} : parent.properties |
| 55 | + properties = merge_properties(parent_properties, data[:properties] || {}) |
| 56 | + if data[:session_id] && !session_property_key?(data[:properties]) |
| 57 | + properties.delete('$session_id') |
| 58 | + properties.delete(:$session_id) |
| 59 | + end |
| 60 | + |
| 61 | + new( |
| 62 | + distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id), |
| 63 | + session_id: data[:session_id] || (fresh || parent.nil? ? nil : parent.session_id), |
| 64 | + properties: properties |
| 65 | + ) |
| 66 | + end |
| 67 | + private_class_method :resolve |
| 68 | + |
| 69 | + def self.merge_data_and_kwargs(data, kwargs) |
| 70 | + data ||= {} |
| 71 | + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) |
| 72 | + |
| 73 | + data.merge(kwargs) |
| 74 | + end |
| 75 | + private_class_method :merge_data_and_kwargs |
| 76 | + |
| 77 | + def self.merge_properties(base, overrides) |
| 78 | + merged = (base || {}).dup |
| 79 | + (overrides || {}).each do |key, value| |
| 80 | + merged.delete(key.to_s) if key.is_a?(Symbol) |
| 81 | + merged.delete(key.to_sym) if key.is_a?(String) |
| 82 | + merged[key] = value |
| 83 | + end |
| 84 | + merged |
| 85 | + end |
| 86 | + |
| 87 | + def self.normalize_data(data) |
| 88 | + data ||= {} |
| 89 | + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) |
| 90 | + |
| 91 | + properties = data[:properties] || data['properties'] || {} |
| 92 | + raise ArgumentError, 'context properties must be a Hash' unless properties.is_a?(Hash) |
| 93 | + |
| 94 | + { |
| 95 | + distinct_id: data[:distinct_id] || data['distinct_id'] || data[:distinctId] || data['distinctId'], |
| 96 | + session_id: data[:session_id] || data['session_id'] || data[:sessionId] || data['sessionId'], |
| 97 | + properties: properties |
| 98 | + } |
| 99 | + end |
| 100 | + private_class_method :normalize_data |
| 101 | + |
| 102 | + def self.session_property_key?(properties) |
| 103 | + return false unless properties.is_a?(Hash) |
| 104 | + |
| 105 | + properties.key?('$session_id') || properties.key?(:$session_id) |
| 106 | + end |
| 107 | + private_class_method :session_property_key? |
| 108 | + |
| 109 | + def apply_session_property! |
| 110 | + return if session_id.nil? || properties.key?('$session_id') || properties.key?(:$session_id) |
| 111 | + |
| 112 | + properties['$session_id'] = session_id |
| 113 | + end |
| 114 | + private :apply_session_property! |
| 115 | + end |
| 116 | + end |
| 117 | + |
| 118 | + private_constant :Internal |
| 119 | +end |
0 commit comments