posthog-ruby 3.23.7 → 3.24.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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Translates a processed internal event (string keys) into one or two
6
+ # PostHog payloads: the main `$mcp_*` event plus an optional `$exception`
7
+ # sibling.
8
+ #
9
+ # @api private
10
+ module EventBuilder
11
+ module_function
12
+
13
+ # @return [Array<Hash>] payloads `{'event', 'distinct_id', 'properties', 'timestamp'}`
14
+ def build(event, enable_exception_autocapture: true)
15
+ batch = [build_capture_event(event)]
16
+ if event['is_error'] && truthy?(event['error']) && enable_exception_autocapture != false
17
+ batch << build_exception_event(event)
18
+ end
19
+ batch
20
+ end
21
+
22
+ def distinct_id(event)
23
+ present(event['identify_actor_given_id']) || present(event['session_id']) || 'anonymous'
24
+ end
25
+
26
+ def timestamp(event)
27
+ event['timestamp'] || Time.now.utc
28
+ end
29
+
30
+ def build_capture_event(event)
31
+ properties = { Property::SOURCE => SOURCE }
32
+ add_session_id(event, properties)
33
+ add_conversation_id(event, properties)
34
+ add_person_processing(event, properties)
35
+ add_groups(event, properties)
36
+ add_common_properties(event, properties)
37
+ add_custom_properties(event, properties)
38
+
39
+ name = present(event['event_name']) || EventType::EVENT_NAME_BY_TYPE.fetch(event['event_type'], Event::CUSTOM)
40
+ { 'event' => name, 'distinct_id' => distinct_id(event), 'properties' => properties,
41
+ 'timestamp' => timestamp(event) }
42
+ end
43
+
44
+ def add_session_id(event, properties)
45
+ session_id = event['session_id']
46
+ properties[Property::SESSION_ID] = session_id if session_id.is_a?(String) && !session_id.empty?
47
+ end
48
+
49
+ def add_conversation_id(event, properties)
50
+ conversation_id = event['conversation_id']
51
+ properties[Property::CONVERSATION_ID] = conversation_id unless conversation_id.nil? || conversation_id == ''
52
+ end
53
+
54
+ def add_groups(event, properties)
55
+ groups = event['groups']
56
+ properties['$groups'] = groups if truthy?(groups)
57
+ end
58
+
59
+ # Without a resolved identity the distinct id is just the session id, so
60
+ # processing a person profile would mint one anonymous person per session.
61
+ def add_person_processing(event, properties)
62
+ properties['$process_person_profile'] = false unless present(event['identify_actor_given_id'])
63
+ end
64
+
65
+ def tool_call?(event)
66
+ event['event_type'] == EventType::MCP_TOOLS_CALL
67
+ end
68
+
69
+ def add_common_properties(event, properties)
70
+ if present(event['resource_name'])
71
+ properties[Property::RESOURCE_NAME] = event['resource_name']
72
+ properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event)
73
+ end
74
+ if present(event['tool_description']) && tool_call?(event)
75
+ properties[Property::TOOL_DESCRIPTION] =
76
+ event['tool_description']
77
+ end
78
+ if present(event['tool_category']) && tool_call?(event)
79
+ properties[Property::TOOL_CATEGORY] = event['tool_category']
80
+ end
81
+ listed = event['listed_tool_names']
82
+ if listed.is_a?(Array) && !listed.empty? && event['event_type'] == EventType::MCP_TOOLS_LIST
83
+ properties[Property::LISTED_TOOL_NAMES] = listed
84
+ end
85
+ properties[Property::DURATION_MS] = event['duration'] unless event['duration'].nil?
86
+ properties[Property::SERVER_NAME] = event['server_name'] if present(event['server_name'])
87
+ properties[Property::SERVER_VERSION] = event['server_version'] if present(event['server_version'])
88
+ properties[Property::CLIENT_NAME] = event['client_name'] if present(event['client_name'])
89
+ properties[Property::CLIENT_VERSION] = event['client_version'] if present(event['client_version'])
90
+ properties[Property::CLIENT_USER_AGENT] = event['client_user_agent'] if present(event['client_user_agent'])
91
+ properties[Property::VENDOR_CLIENT] = event['vendor_client'] if present(event['vendor_client'])
92
+ properties[Property::PROTOCOL_VERSION] = event['protocol_version'] if present(event['protocol_version'])
93
+ properties[Property::INTENT] = event['user_intent'] if present(event['user_intent'])
94
+ properties[Property::INTENT_SOURCE] = event['user_intent_source'] if present(event['user_intent_source'])
95
+ properties[Property::LLM_MODEL] = event['llm_model'] if present(event['llm_model'])
96
+ properties[Property::LLM_MODEL_SOURCE] = event['llm_model_source'] if present(event['llm_model_source'])
97
+ properties[Property::IS_ERROR] = event['is_error'] unless event['is_error'].nil?
98
+ add_error_details(event, properties) if event['is_error']
99
+ properties[Property::PARAMETERS] = event['parameters'] unless event['parameters'].nil?
100
+ properties[Property::RESPONSE] = event['response'] unless event['response'].nil?
101
+ actor_data = event['identify_actor_data']
102
+ properties['$set'] = actor_data.dup if actor_data.is_a?(Hash) && !actor_data.empty?
103
+ end
104
+
105
+ # Surface the failure reason on the primary event itself, so dashboards
106
+ # need not join to the `$exception` sibling (which can be switched off).
107
+ def add_error_details(event, properties)
108
+ first = Exceptions.primary_exception(event['error'])
109
+ error_type = present(event['error_type']) || present(first['type'])
110
+ properties[Property::ERROR_TYPE] = error_type if error_type
111
+ message = first['value']
112
+ properties[Property::ERROR_MESSAGE] = message if present(message)
113
+ end
114
+
115
+ def add_custom_properties(event, properties)
116
+ custom = event['properties']
117
+ return unless custom.is_a?(Hash)
118
+
119
+ custom.each { |key, value| properties[key.to_s] = value }
120
+ end
121
+
122
+ def build_exception_event(event)
123
+ properties = {}
124
+ add_session_id(event, properties)
125
+ add_conversation_id(event, properties)
126
+ add_person_processing(event, properties)
127
+ add_groups(event, properties)
128
+
129
+ error = event['error']
130
+ properties.merge!(error) if error.is_a?(Hash)
131
+
132
+ if present(event['resource_name'])
133
+ properties[Property::RESOURCE_NAME] = event['resource_name']
134
+ properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event)
135
+ end
136
+ if present(event['tool_description']) && tool_call?(event)
137
+ properties[Property::TOOL_DESCRIPTION] =
138
+ event['tool_description']
139
+ end
140
+ if present(event['tool_category']) && tool_call?(event)
141
+ properties[Property::TOOL_CATEGORY] = event['tool_category']
142
+ end
143
+ properties[Property::SERVER_NAME] = event['server_name'] if present(event['server_name'])
144
+ properties[Property::SERVER_VERSION] = event['server_version'] if present(event['server_version'])
145
+ properties[Property::CLIENT_NAME] = event['client_name'] if present(event['client_name'])
146
+ properties[Property::CLIENT_VERSION] = event['client_version'] if present(event['client_version'])
147
+ properties[Property::PROTOCOL_VERSION] = event['protocol_version'] if present(event['protocol_version'])
148
+
149
+ add_custom_properties(event, properties)
150
+
151
+ { 'event' => Event::EXCEPTION, 'distinct_id' => distinct_id(event), 'properties' => properties,
152
+ 'timestamp' => timestamp(event) }
153
+ end
154
+
155
+ def present(value)
156
+ return nil if value.nil?
157
+ return nil if value.respond_to?(:empty?) && value.empty?
158
+ return nil if value == false
159
+
160
+ value
161
+ end
162
+
163
+ def truthy?(value)
164
+ !present(value).nil?
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/exception_capture'
4
+
5
+ module PostHog
6
+ module MCP
7
+ # Builds PostHog error-tracking properties (`$exception_list` /
8
+ # `$exception_level`) from anything a tool can fail with, reusing
9
+ # {PostHog::ExceptionCapture} so MCP failures group like every other
10
+ # exception the Ruby SDK reports.
11
+ #
12
+ # @api private
13
+ module Exceptions
14
+ GENERIC_MECHANISM = { 'type' => 'generic', 'handled' => true }.freeze
15
+
16
+ # Messages the Ruby `mcp` gem wraps around whatever a tool raised.
17
+ DISPATCH_WRAPPER_TYPE = 'MCP::Server::RequestHandlerError'
18
+ DISPATCH_WRAPPER_PREFIXES = ['Internal error calling tool', 'Internal error handling'].freeze
19
+
20
+ module_function
21
+
22
+ # @param error [Exception, String, Hash, Object] exception, message, or an
23
+ # `isError` tool result (`{content: [...], isError: true}`)
24
+ # @return [Hash] `{'$exception_list' => [...], '$exception_level' => 'error'}`
25
+ def capture_exception(error)
26
+ return from_message(call_tool_result_message(error)) if call_tool_result?(error)
27
+ return from_exception(error) if error.is_a?(Exception)
28
+ return from_message(error) if error.is_a?(String)
29
+
30
+ from_message(safe_to_s(error))
31
+ end
32
+
33
+ def from_exception(error)
34
+ list = PostHog::ExceptionCapture.build_exception_list(error) || []
35
+ original = error.respond_to?(:original_error) ? error.original_error : nil
36
+ if original.is_a?(Exception) && !chain_includes?(error, original)
37
+ list.concat(PostHog::ExceptionCapture.build_exception_list(original) || [])
38
+ end
39
+ list = [message_entry(error.class.to_s, error.message.to_s)] if list.empty?
40
+ { '$exception_list' => list, '$exception_level' => 'error' }
41
+ end
42
+
43
+ def from_message(message)
44
+ { '$exception_list' => [message_entry('Error', message)], '$exception_level' => 'error' }
45
+ end
46
+
47
+ def message_entry(type, message)
48
+ { 'mechanism' => GENERIC_MECHANISM.dup, 'type' => type, 'value' => message }
49
+ end
50
+
51
+ # Whether an `$exception_list` entry is the gem's dispatch wrapper, whose
52
+ # message says nothing the tool name does not already say.
53
+ def dispatch_wrapper?(entry)
54
+ return false unless entry.is_a?(Hash)
55
+
56
+ value = entry['value'].to_s
57
+ entry['type'] == DISPATCH_WRAPPER_TYPE && DISPATCH_WRAPPER_PREFIXES.any? { |p| value.start_with?(p) }
58
+ end
59
+
60
+ # The `$exception_list` entry carrying the actual failure reason, stepping
61
+ # past consecutive dispatch wrappers.
62
+ def primary_exception(error)
63
+ return {} unless error.is_a?(Hash)
64
+
65
+ list = error['$exception_list']
66
+ return {} unless list.is_a?(Array) && !list.empty?
67
+
68
+ index = 0
69
+ index += 1 while index + 1 < list.length && list[index + 1].is_a?(Hash) && dispatch_wrapper?(list[index])
70
+ list[index].is_a?(Hash) ? list[index] : {}
71
+ end
72
+
73
+ def call_tool_result?(value)
74
+ return false unless value.is_a?(Hash)
75
+
76
+ content = value['content'] || value[:content]
77
+ (value.key?('isError') || value.key?(:isError)) && content.is_a?(Array)
78
+ end
79
+
80
+ def call_tool_result_message(result)
81
+ content = result['content'] || result[:content] || []
82
+ texts = content.filter_map do |part|
83
+ next unless part.is_a?(Hash)
84
+
85
+ type = part['type'] || part[:type]
86
+ text = part['text'] || part[:text]
87
+ text if type == 'text' && text.is_a?(String)
88
+ end
89
+ joined = texts.join(' ').strip
90
+ joined.empty? ? 'Unknown error' : joined
91
+ end
92
+
93
+ def chain_includes?(error, target)
94
+ current = error.cause
95
+ seen = {}.compare_by_identity
96
+ while current && !seen.key?(current)
97
+ return true if current.equal?(target)
98
+
99
+ seen[current] = true
100
+ current = current.cause
101
+ end
102
+ false
103
+ end
104
+
105
+ def safe_to_s(value)
106
+ value.to_s
107
+ rescue StandardError
108
+ 'Unknown error'
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PostHog
6
+ module MCP
7
+ # Bounded LRU of session identities, isolated per server.
8
+ #
9
+ # @api private
10
+ class IdentityCache
11
+ def initialize(max_size = 1000)
12
+ @cache = {}
13
+ @max_size = max_size
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def get(session_id)
18
+ @mutex.synchronize do
19
+ identity = @cache.delete(session_id)
20
+ next nil if identity.nil?
21
+
22
+ @cache[session_id] = identity
23
+ end
24
+ end
25
+
26
+ def set(session_id, identity)
27
+ @mutex.synchronize do
28
+ @cache.delete(session_id)
29
+ @cache.shift if @cache.length >= @max_size
30
+ @cache[session_id] = identity
31
+ end
32
+ end
33
+
34
+ # Atomically read the cached identity, merge the new one, store it, and
35
+ # report whether it changed. Keeps concurrent requests for one session
36
+ # from interleaving their read-merge-write.
37
+ #
38
+ # @return [Array(UserIdentity, Boolean)] `[merged, changed]`
39
+ def merge!(session_id, identity)
40
+ @mutex.synchronize do
41
+ previous = @cache.delete(session_id)
42
+ merged = Identity.merge_identities(previous, identity)
43
+ changed = !(previous && Identity.identities_equal?(previous, merged))
44
+ @cache.shift if @cache.length >= @max_size
45
+ @cache[session_id] = merged
46
+ [merged, changed]
47
+ end
48
+ end
49
+
50
+ def has?(session_id)
51
+ @mutex.synchronize { @cache.key?(session_id) }
52
+ end
53
+
54
+ def size
55
+ @mutex.synchronize { @cache.length }
56
+ end
57
+ end
58
+
59
+ # Identity resolution: runs the `identify` option, dedupes against the
60
+ # per-server cache, and decides when a standalone `$identify` event fires.
61
+ #
62
+ # @api private
63
+ module Identity
64
+ module_function
65
+
66
+ def identities_equal?(first, second)
67
+ return false if first.distinct_id != second.distinct_id
68
+ return false if sorted_json(first.groups || {}) != sorted_json(second.groups || {})
69
+
70
+ a_props = first.properties || {}
71
+ b_props = second.properties || {}
72
+ return false if a_props.keys.map(&:to_s).sort != b_props.keys.map(&:to_s).sort
73
+
74
+ a_props.all? do |key, value|
75
+ other = if b_props.key?(key)
76
+ b_props[key]
77
+ else
78
+ b_props[key.is_a?(Symbol) ? key.to_s : key.to_s.to_sym]
79
+ end
80
+ sorted_json(value) == sorted_json(other)
81
+ end
82
+ end
83
+
84
+ def merge_identities(previous, nxt)
85
+ return nxt if previous.nil?
86
+
87
+ UserIdentity.new(
88
+ distinct_id: nxt.distinct_id,
89
+ properties: (previous.properties || {}).merge(nxt.properties || {}),
90
+ groups: nxt.groups.nil? ? previous.groups : nxt.groups
91
+ )
92
+ end
93
+
94
+ # Resolve the optional `identify` callback for one request: the identity the
95
+ # request's events belong to, plus an `$identify` event to emit only when
96
+ # that identity has materially changed.
97
+ #
98
+ # The actor is handed back rather than left for the caller to read out of
99
+ # {IdentityCache} later. The cache is keyed by session, and a request's
100
+ # events are built after its handler returns, so a concurrent request on
101
+ # the same session would otherwise decide who this one is attributed to.
102
+ # When resolution yields nothing the cache is read once, here, so whatever
103
+ # a request is attributed to it is attributed to consistently.
104
+ #
105
+ # @return [Array(Hash, UserIdentity), Array(nil, UserIdentity), Array(nil, nil)] `[event, actor]`
106
+ def identify_for_request(data, session_id, request, extra)
107
+ identify = data.options.identify
108
+ return [nil, nil] unless identify
109
+
110
+ result = if identify.is_a?(UserIdentity) || identify.is_a?(Hash)
111
+ identify
112
+ else
113
+ Callbacks.call(identify, request,
114
+ extra)
115
+ end
116
+ identity = UserIdentity.coerce(result)
117
+ unless identity
118
+ Log.debug(data.options, "Warning: Supplied identify function returned null for session #{session_id}")
119
+ return [nil, data.identified_sessions.get(session_id)]
120
+ end
121
+
122
+ merged, changed = data.identified_sessions.merge!(session_id, identity)
123
+ return [nil, merged] unless changed
124
+
125
+ Log.debug(data.options, "Identified session #{session_id}")
126
+ [{
127
+ 'session_id' => session_id,
128
+ 'resource_name' => request_resource_name(request),
129
+ 'event_type' => EventType::IDENTIFY,
130
+ 'parameters' => { 'request' => request, 'extra' => captured_extra(extra) },
131
+ 'timestamp' => Time.now.utc
132
+ }, merged]
133
+ rescue StandardError => e
134
+ Log.debug(data.options, "Error: identify function threw while identifying session #{session_id} - #{e.message}")
135
+ [nil, data.identified_sessions.get(session_id)]
136
+ end
137
+
138
+ # @return [Hash, nil] the `$identify` event alone; see {identify_for_request}.
139
+ def handle_identify(data, session_id, request, extra)
140
+ identify_for_request(data, session_id, request, extra).first
141
+ end
142
+
143
+ def request_resource_name(request)
144
+ return 'Unknown' unless request.is_a?(Hash)
145
+
146
+ params = request[:params] || request['params']
147
+ return 'Unknown' unless params.is_a?(Hash)
148
+
149
+ name = params[:name] || params['name']
150
+ name.is_a?(String) ? name : 'Unknown'
151
+ end
152
+
153
+ # Only JSON scalars from `extra` are captured; never opaque transport objects.
154
+ def captured_extra(extra)
155
+ return nil unless extra.is_a?(Hash)
156
+
157
+ extra.select do |_, value|
158
+ value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
159
+ end
160
+ end
161
+
162
+ def sorted_json(value)
163
+ JSON.generate(sort_deep(value))
164
+ rescue StandardError
165
+ value.to_s
166
+ end
167
+
168
+ def sort_deep(value)
169
+ case value
170
+ when Hash then value.map { |k, v| [k.to_s, sort_deep(v)] }.sort_by(&:first).to_h
171
+ when Array then value.map { |v| sort_deep(v) }
172
+ when String, Numeric, true, false, nil then value
173
+ else value.to_s
174
+ end
175
+ end
176
+ end
177
+
178
+ # Invokes user callbacks with `(request, extra)`, tolerating 1-arity lambdas.
179
+ #
180
+ # @api private
181
+ module Callbacks
182
+ module_function
183
+
184
+ def call(callable, request, extra)
185
+ arity = callable.respond_to?(:arity) ? callable.arity : 2
186
+ case arity
187
+ when 0 then callable.call
188
+ when 1 then callable.call(request)
189
+ else callable.call(request, extra)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module PostHog
6
+ module MCP
7
+ # Id generation: `evt_<uuidv7>` / `ses_<uuidv7>` and the deterministic FNV-1a
8
+ # derivation used for session ids that must agree across servers and restarts.
9
+ #
10
+ # @api private
11
+ module Ids
12
+ module_function
13
+
14
+ # RFC 9562 UUIDv7 (time-ordered), implemented inline so Ruby 3.0/3.1 work too.
15
+ #
16
+ # @return [String] lowercase, hyphenated uuid
17
+ def uuid_v7
18
+ unix_ts_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) & ((1 << 48) - 1)
19
+ rand_a = SecureRandom.random_number(1 << 12)
20
+ rand_b = SecureRandom.random_number(1 << 62)
21
+
22
+ value = unix_ts_ms << 80
23
+ value |= 0x7 << 76
24
+ value |= rand_a << 64
25
+ value |= 0b10 << 62
26
+ value |= rand_b
27
+
28
+ hex = format('%032x', value)
29
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
30
+ end
31
+
32
+ # @param prefix [String] `'evt'` or `'ses'`
33
+ def new_prefixed_id(prefix)
34
+ "#{prefix}_#{uuid_v7}"
35
+ end
36
+
37
+ # Deterministic id derived from an arbitrary string. FNV-1a 64-bit mixed
38
+ # twice to fill 32 hex chars. Not cryptographic; only stable and low-collision.
39
+ #
40
+ # Iterates code points; session/conversation ids are ASCII in practice.
41
+ def deterministic_prefixed_id(prefix, value)
42
+ "#{prefix}_#{fnv1a_hex(value)}#{fnv1a_hex("#{value}::salt")}"
43
+ end
44
+
45
+ def fnv1a_hex(value)
46
+ h1 = 0x84222325
47
+ h2 = 0xcbf29ce4
48
+ value.to_s.each_codepoint do |c|
49
+ h1 = ((h1 ^ c) * 0x000001b3) & 0xffffffff
50
+ h2 = ((h2 ^ c) * 0x00000193) & 0xffffffff
51
+ end
52
+ format('%<h1>08x%<h2>08x', h1: h1, h2: h2)
53
+ end
54
+ end
55
+ end
56
+ end