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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +12 -6
- data/lib/posthog/feature_flags.rb +155 -60
- data/lib/posthog/flag_definition_cache.rb +9 -5
- data/lib/posthog/mcp/README.md +53 -0
- data/lib/posthog/mcp/analytics.rb +116 -0
- data/lib/posthog/mcp/client.rb +246 -0
- data/lib/posthog/mcp/constants.rb +111 -0
- data/lib/posthog/mcp/conversation_id.rb +89 -0
- data/lib/posthog/mcp/event_builder.rb +168 -0
- data/lib/posthog/mcp/exceptions.rb +112 -0
- data/lib/posthog/mcp/identity.rb +194 -0
- data/lib/posthog/mcp/ids.rb +56 -0
- data/lib/posthog/mcp/instrumentation.rb +729 -0
- data/lib/posthog/mcp/intent.rb +101 -0
- data/lib/posthog/mcp/log.rb +37 -0
- data/lib/posthog/mcp/options.rb +128 -0
- data/lib/posthog/mcp/rack_middleware.rb +112 -0
- data/lib/posthog/mcp/request_scope.rb +72 -0
- data/lib/posthog/mcp/sanitization.rb +504 -0
- data/lib/posthog/mcp/schema_mutation.rb +179 -0
- data/lib/posthog/mcp/server_extension.rb +54 -0
- data/lib/posthog/mcp/session.rb +71 -0
- data/lib/posthog/mcp/session_token.rb +106 -0
- data/lib/posthog/mcp/sink.rb +98 -0
- data/lib/posthog/mcp/tools.rb +91 -0
- data/lib/posthog/mcp/tracking_data.rb +89 -0
- data/lib/posthog/mcp/truncation.rb +326 -0
- data/lib/posthog/mcp.rb +216 -0
- data/lib/posthog/version.rb +1 -1
- metadata +26 -1
|
@@ -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
|