txlemetry-ruby 4.0.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 +7 -0
- data/LICENSE +48 -0
- data/NOTICE +5 -0
- data/bin/txlemetry +110 -0
- data/lib/txlemetry/backoff_policy.rb +49 -0
- data/lib/txlemetry/client.rb +1266 -0
- data/lib/txlemetry/defaults.rb +50 -0
- data/lib/txlemetry/exception_capture.rb +246 -0
- data/lib/txlemetry/feature_flag.rb +85 -0
- data/lib/txlemetry/feature_flag_error.rb +37 -0
- data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
- data/lib/txlemetry/feature_flag_result.rb +81 -0
- data/lib/txlemetry/feature_flags.rb +1516 -0
- data/lib/txlemetry/field_parser.rb +219 -0
- data/lib/txlemetry/flag_definition_cache.rb +88 -0
- data/lib/txlemetry/internal/context.rb +119 -0
- data/lib/txlemetry/logging.rb +77 -0
- data/lib/txlemetry/mcp/README.md +53 -0
- data/lib/txlemetry/mcp/analytics.rb +116 -0
- data/lib/txlemetry/mcp/client.rb +257 -0
- data/lib/txlemetry/mcp/constants.rb +111 -0
- data/lib/txlemetry/mcp/conversation_id.rb +89 -0
- data/lib/txlemetry/mcp/event_builder.rb +168 -0
- data/lib/txlemetry/mcp/exceptions.rb +112 -0
- data/lib/txlemetry/mcp/identity.rb +194 -0
- data/lib/txlemetry/mcp/ids.rb +56 -0
- data/lib/txlemetry/mcp/instrumentation.rb +736 -0
- data/lib/txlemetry/mcp/intent.rb +101 -0
- data/lib/txlemetry/mcp/log.rb +37 -0
- data/lib/txlemetry/mcp/options.rb +128 -0
- data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
- data/lib/txlemetry/mcp/request_scope.rb +72 -0
- data/lib/txlemetry/mcp/sanitization.rb +504 -0
- data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
- data/lib/txlemetry/mcp/server_extension.rb +54 -0
- data/lib/txlemetry/mcp/session.rb +71 -0
- data/lib/txlemetry/mcp/session_token.rb +106 -0
- data/lib/txlemetry/mcp/sink.rb +98 -0
- data/lib/txlemetry/mcp/tools.rb +91 -0
- data/lib/txlemetry/mcp/tracking_data.rb +89 -0
- data/lib/txlemetry/mcp/truncation.rb +326 -0
- data/lib/txlemetry/mcp.rb +224 -0
- data/lib/txlemetry/message_batch.rb +80 -0
- data/lib/txlemetry/noop_worker.rb +39 -0
- data/lib/txlemetry/response.rb +17 -0
- data/lib/txlemetry/send_feature_flags_options.rb +49 -0
- data/lib/txlemetry/send_worker.rb +218 -0
- data/lib/txlemetry/transport.rb +216 -0
- data/lib/txlemetry/utils.rb +183 -0
- data/lib/txlemetry/version.rb +5 -0
- data/lib/txlemetry.rb +16 -0
- metadata +105 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'txlemetry/exception_capture'
|
|
4
|
+
|
|
5
|
+
module Txlemetry
|
|
6
|
+
module MCP
|
|
7
|
+
# Builds Txlemetry error-tracking properties (`$exception_list` /
|
|
8
|
+
# `$exception_level`) from anything a tool can fail with, reusing
|
|
9
|
+
# {Txlemetry::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 = Txlemetry::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(Txlemetry::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 Txlemetry
|
|
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 Txlemetry
|
|
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
|