posthog-ruby 3.28.4 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/unresolved_flag_reason'
4
+
5
+ module PostHog
6
+ # InconclusiveMatchError is raised when local evaluation cannot determine a flag's
7
+ # value. {#reason} says why, as reported by {FeatureFlagEvaluations#unresolved_flags}.
8
+ class InconclusiveMatchError < StandardError
9
+ # @return [String] One of the {UnresolvedFlagReason} values.
10
+ attr_reader :reason
11
+
12
+ # @param message [String, nil]
13
+ # @param reason [String] One of the {UnresolvedFlagReason} values.
14
+ def initialize(message = nil, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
15
+ super(message)
16
+ @reason = reason
17
+ end
18
+ end
19
+
20
+ # RequiresServerEvaluation is raised when feature flag evaluation requires
21
+ # server-side data that is not available locally (e.g., static cohorts,
22
+ # experience continuity). This error should propagate immediately to trigger
23
+ # API fallback, unlike InconclusiveMatchError which allows trying other conditions.
24
+ class RequiresServerEvaluation < StandardError
25
+ # @return [String] One of the {UnresolvedFlagReason} values.
26
+ attr_reader :reason
27
+
28
+ # @param message [String, nil]
29
+ # @param reason [String] One of the {UnresolvedFlagReason} values.
30
+ def initialize(message = nil, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
31
+ super(message)
32
+ @reason = reason
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'posthog/internal/span_context'
4
+
3
5
  module PostHog
4
6
  module Internal
5
7
  # Internal request/fiber-local context applied to capture calls.
@@ -12,10 +14,12 @@ module PostHog
12
14
  # server-side SDK globally stateful per user.
13
15
  class Context
14
16
  STORAGE_KEY = :posthog_context
17
+ UNBOUND_OWNER = Object.new.freeze
15
18
 
16
- attr_reader :distinct_id, :session_id, :properties
19
+ attr_reader :distinct_id, :session_id, :properties, :owner
17
20
 
18
- def initialize(distinct_id: nil, session_id: nil, properties: {})
21
+ def initialize(distinct_id: nil, session_id: nil, properties: {}, owner: UNBOUND_OWNER)
22
+ @owner = owner
19
23
  @distinct_id = distinct_id
20
24
  @session_id = session_id
21
25
  @properties = properties ? properties.dup : {}
@@ -38,17 +42,18 @@ module PostHog
38
42
  end
39
43
  end
40
44
 
41
- def self.with_context(data = nil, fresh: false, **kwargs)
45
+ def self.with_context(data = nil, fresh: false, owner: UNBOUND_OWNER, enrich_current_span: true, **kwargs)
42
46
  previous_context = current
43
47
  raise ArgumentError, 'with_context requires a block' unless block_given?
44
48
 
45
- self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh)
49
+ self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh, owner: owner)
50
+ SpanContext.apply_current if enrich_current_span
46
51
  yield
47
52
  ensure
48
53
  self.current = previous_context
49
54
  end
50
55
 
51
- def self.resolve(data, parent, fresh: false)
56
+ def self.resolve(data, parent, fresh: false, owner: UNBOUND_OWNER)
52
57
  data = normalize_data(data)
53
58
 
54
59
  parent_properties = fresh || parent.nil? ? {} : parent.properties
@@ -61,7 +66,8 @@ module PostHog
61
66
  new(
62
67
  distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id),
63
68
  session_id: data[:session_id] || (fresh || parent.nil? ? nil : parent.session_id),
64
- properties: properties
69
+ properties: properties,
70
+ owner: owner.equal?(UNBOUND_OWNER) && parent ? parent.owner : owner
65
71
  )
66
72
  end
67
73
  private_class_method :resolve
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module Internal
5
+ class SpanContext
6
+ def self.apply(span)
7
+ context = Context.current
8
+ return unless context && span&.recording? && span.respond_to?(:attributes)
9
+
10
+ return unless context.owner.equal?(Context::UNBOUND_OWNER) || context.owner&.enabled?
11
+
12
+ attributes = span.attributes || {}
13
+ {
14
+ 'posthogDistinctId' => context.distinct_id,
15
+ 'sessionId' => context.session_id
16
+ }.each do |key, value|
17
+ next if value.nil? || value == '' || attributes.key?(key)
18
+
19
+ span.set_attribute(key, value.is_a?(String) ? value.dup.freeze : value)
20
+ end
21
+ rescue StandardError
22
+ nil
23
+ end
24
+
25
+ def self.apply_current
26
+ return unless defined?(::OpenTelemetry::Trace)
27
+
28
+ apply(::OpenTelemetry::Trace.current_span)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -588,7 +588,9 @@ module PostHog
588
588
  'session_id' => (@session.respond_to?(:session_id) ? @session.session_id : nil) || header_session_id,
589
589
  'request_id' => @request_id,
590
590
  'protocol_version' => protocol_version,
591
- 'headers' => @headers,
591
+ # A deep copy, because session and protocol resolution read @headers after
592
+ # callbacks run, and the values are the Rack env's own Strings.
593
+ 'headers' => @headers.transform_values(&:dup),
592
594
  'session' => @session
593
595
  }
594
596
  end
@@ -17,14 +17,6 @@ module PostHog
17
17
  KEY = :posthog_mcp_request_scope
18
18
  FIBER_STORAGE = Fiber.respond_to?(:[]) && Fiber.respond_to?(:[]=)
19
19
 
20
- # Rack `env` keys for the headers the integration reads, by header name.
21
- HEADER_ENV_KEYS = {
22
- 'user-agent' => 'HTTP_USER_AGENT',
23
- 'x-anthropic-client' => 'HTTP_X_ANTHROPIC_CLIENT',
24
- 'mcp-session-id' => 'HTTP_MCP_SESSION_ID',
25
- 'mcp-protocol-version' => 'HTTP_MCP_PROTOCOL_VERSION'
26
- }.freeze
27
-
28
20
  module_function
29
21
 
30
22
  # @return [Hash, nil] `{headers:, transport:, mint:, session_id:}` for the in-flight HTTP request,
@@ -46,20 +38,39 @@ module PostHog
46
38
  end
47
39
  end
48
40
 
49
- # @param headers [Hash{String => String}] lowercase header names
50
- # @param transport [Symbol] `:http` for the Streamable HTTP transport, `:other`
51
- # for a transport that publishes no headers (stdio, a custom dispatcher)
41
+ # Rack `env` keys for the two request headers Rack stores without `HTTP_`.
42
+ UNPREFIXED_HEADER_ENV_KEYS = { 'CONTENT_TYPE' => 'content-type', 'CONTENT_LENGTH' => 'content-length' }.freeze
43
+ # The request line's protocol, which some servers (WEBrick, Thin) put in `env`.
44
+ PROTOCOL_ENV_KEY = 'HTTP_VERSION'
45
+
46
+ # Every request header, so `identify`, `intent_fallback` and `event_properties`
47
+ # can read the ones they need (an `authorization` bearer token, an API key)
48
+ # from `extra['headers']`. The SDK doesn't capture this bag: {Identity} keeps
49
+ # only scalars from `extra`. {TransportIdentity} still stamps `user-agent` and
50
+ # `x-anthropic-client`, as before. A callback that returns a raw header value in
51
+ # identity properties or `event_properties` sends it, unredacted.
52
+ #
52
53
  # @param env [Hash] a Rack `env`
53
- # @return [Hash{String => String}] the headers {Instrumentation} reads, lowercase
54
+ # @return [Hash{String => String}] lowercase header names
54
55
  def headers_from_env(env)
55
56
  return {} unless env.is_a?(Hash)
56
57
 
57
- HEADER_ENV_KEYS.each_with_object({}) do |(name, env_key), acc|
58
- value = env[env_key]
59
- acc[name] = value if value.is_a?(String) && !value.empty?
58
+ env.each_with_object({}) do |(key, value), acc|
59
+ name = header_name(key)
60
+ acc[name] = value if name && value.is_a?(String) && !value.empty?
60
61
  end
61
62
  end
62
63
 
64
+ def header_name(env_key)
65
+ return UNPREFIXED_HEADER_ENV_KEYS[env_key] unless env_key.is_a?(String) && env_key.start_with?('HTTP_')
66
+ return nil if env_key == PROTOCOL_ENV_KEY
67
+
68
+ env_key.delete_prefix('HTTP_').downcase.tr('_', '-')
69
+ end
70
+
71
+ # @param headers [Hash{String => String}] lowercase header names
72
+ # @param transport [Symbol] `:http` for the Streamable HTTP transport, `:other`
73
+ # for a transport that publishes no headers (stdio, a custom dispatcher)
63
74
  def with(headers:, transport: :http)
64
75
  previous = current
65
76
  self.current = { headers: headers, transport: transport, mint: nil, session_id: nil }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/internal/context'
4
+
5
+ module PostHog
6
+ class ContextSpanProcessor
7
+ def on_start(span, _parent_context)
8
+ Internal::SpanContext.apply(span)
9
+ end
10
+
11
+ def on_finish(_span); end
12
+
13
+ def force_flush(**_options)
14
+ 0
15
+ end
16
+
17
+ def shutdown(**_options)
18
+ 0
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ # Why local evaluation left a flag without a value, as reported by
5
+ # {FeatureFlagEvaluations#unresolved_flags}.
6
+ #
7
+ # Reason values:
8
+ # EXPERIENCE_CONTINUITY: The flag has experience continuity enabled, which local evaluation never resolves
9
+ # UNSUPPORTED_DEFINITION: The flag uses something local evaluation does not support, such as a static
10
+ # cohort, an unknown operator, a malformed value, or a missing or circular flag dependency
11
+ # UNRESOLVED_DEPENDENCY: A flag this flag depends on has a loaded definition but was unresolved
12
+ # MISSING_CONTEXT: The call did not pass a property or group property the flag needs, or passed it in a
13
+ # form local evaluation cannot use
14
+ module UnresolvedFlagReason
15
+ EXPERIENCE_CONTINUITY = 'experience_continuity'
16
+ UNSUPPORTED_DEFINITION = 'unsupported_definition'
17
+ UNRESOLVED_DEPENDENCY = 'unresolved_dependency'
18
+ MISSING_CONTEXT = 'missing_context'
19
+ end
20
+ end
data/lib/posthog/utils.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module PostHog
4
- class InconclusiveMatchError < StandardError
5
- end
3
+ require 'posthog/flag_evaluation_errors'
6
4
 
5
+ module PostHog
7
6
  # Utility helpers used internally by the SDK.
8
7
  #
9
8
  # @api private
@@ -92,7 +91,7 @@ module PostHog
92
91
  seconds.abs / 3600, (seconds.abs % 3600) / 60)
93
92
  end
94
93
 
95
- def convert_to_datetime(value)
94
+ def convert_to_datetime(value, reason:)
96
95
  if value.respond_to?(:strftime)
97
96
  value
98
97
 
@@ -100,10 +99,10 @@ module PostHog
100
99
  begin
101
100
  DateTime.parse(value)
102
101
  rescue ArgumentError
103
- raise InconclusiveMatchError, "#{value} is not in a valid date format"
102
+ raise InconclusiveMatchError.new("#{value} is not in a valid date format", reason: reason)
104
103
  end
105
104
  else
106
- raise InconclusiveMatchError, 'The date provided must be a string or date object'
105
+ raise InconclusiveMatchError.new('The date provided must be a string or date object', reason: reason)
107
106
  end
108
107
  end
109
108
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.28.4'
4
+ VERSION = '3.29.0'
5
5
  end
data/lib/posthog.rb CHANGED
@@ -11,6 +11,7 @@ require 'posthog/response'
11
11
  require 'posthog/logging'
12
12
  require 'posthog/exception_capture'
13
13
  require 'posthog/feature_flag_error'
14
+ require 'posthog/unresolved_flag_reason'
14
15
  require 'posthog/feature_flag_result'
15
16
  require 'posthog/feature_flag_evaluations'
16
17
  require 'posthog/flag_definition_cache'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.28.4
4
+ version: 3.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -46,7 +46,9 @@ files:
46
46
  - lib/posthog/feature_flags.rb
47
47
  - lib/posthog/field_parser.rb
48
48
  - lib/posthog/flag_definition_cache.rb
49
+ - lib/posthog/flag_evaluation_errors.rb
49
50
  - lib/posthog/internal/context.rb
51
+ - lib/posthog/internal/span_context.rb
50
52
  - lib/posthog/logging.rb
51
53
  - lib/posthog/mcp.rb
52
54
  - lib/posthog/mcp/README.md
@@ -75,10 +77,12 @@ files:
75
77
  - lib/posthog/mcp/truncation.rb
76
78
  - lib/posthog/message_batch.rb
77
79
  - lib/posthog/noop_worker.rb
80
+ - lib/posthog/otel/context_span_processor.rb
78
81
  - lib/posthog/response.rb
79
82
  - lib/posthog/send_feature_flags_options.rb
80
83
  - lib/posthog/send_worker.rb
81
84
  - lib/posthog/transport.rb
85
+ - lib/posthog/unresolved_flag_reason.rb
82
86
  - lib/posthog/utils.rb
83
87
  - lib/posthog/version.rb
84
88
  homepage: https://github.com/PostHog/posthog-ruby