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,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
require 'txlemetry/logging'
|
|
6
|
+
|
|
7
|
+
module Txlemetry
|
|
8
|
+
# Converts public SDK method arguments into Txlemetry API event payloads.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class FieldParser
|
|
12
|
+
class << self
|
|
13
|
+
include Txlemetry::Utils
|
|
14
|
+
include Txlemetry::Logging
|
|
15
|
+
|
|
16
|
+
# In addition to the common fields, capture accepts:
|
|
17
|
+
#
|
|
18
|
+
# - "event"
|
|
19
|
+
# - "properties"
|
|
20
|
+
# - "groups"
|
|
21
|
+
# - "uuid"
|
|
22
|
+
# @param fields [Hash]
|
|
23
|
+
# @return [Hash]
|
|
24
|
+
def parse_for_capture(fields)
|
|
25
|
+
common = parse_common_fields(fields)
|
|
26
|
+
|
|
27
|
+
event = fields[:event]
|
|
28
|
+
properties = fields[:properties] || {}
|
|
29
|
+
groups = fields[:groups]
|
|
30
|
+
check_presence!(event, 'event')
|
|
31
|
+
check_is_hash!(properties, 'properties')
|
|
32
|
+
|
|
33
|
+
if groups
|
|
34
|
+
check_is_hash!(groups, 'groups')
|
|
35
|
+
properties['$groups'] = groups
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
isoify_dates! properties
|
|
39
|
+
|
|
40
|
+
common.merge(
|
|
41
|
+
{
|
|
42
|
+
type: 'capture',
|
|
43
|
+
event: event.to_s,
|
|
44
|
+
properties: properties.merge(common[:properties] || {})
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# In addition to the common fields, identify accepts:
|
|
50
|
+
#
|
|
51
|
+
# - "properties"
|
|
52
|
+
# @param fields [Hash]
|
|
53
|
+
# @return [Hash]
|
|
54
|
+
def parse_for_identify(fields)
|
|
55
|
+
common = parse_common_fields(fields)
|
|
56
|
+
|
|
57
|
+
properties = fields[:properties] || {}
|
|
58
|
+
check_is_hash!(properties, 'properties')
|
|
59
|
+
|
|
60
|
+
isoify_dates! properties
|
|
61
|
+
|
|
62
|
+
common.merge(
|
|
63
|
+
{
|
|
64
|
+
type: 'identify',
|
|
65
|
+
event: '$identify',
|
|
66
|
+
'$set': properties,
|
|
67
|
+
properties: properties.merge(common[:properties] || {})
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param fields [Hash]
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def parse_for_group_identify(fields)
|
|
75
|
+
properties = fields[:properties] || {}
|
|
76
|
+
group_type = fields[:group_type]
|
|
77
|
+
group_key = fields[:group_key]
|
|
78
|
+
|
|
79
|
+
check_presence!(group_type, 'group type')
|
|
80
|
+
check_presence!(group_key, 'group_key')
|
|
81
|
+
check_is_hash!(properties, 'properties')
|
|
82
|
+
|
|
83
|
+
fields[:distinct_id] ||= "$#{group_type}_#{group_key}"
|
|
84
|
+
common = parse_common_fields(fields)
|
|
85
|
+
|
|
86
|
+
isoify_dates! properties
|
|
87
|
+
|
|
88
|
+
common.merge(
|
|
89
|
+
{
|
|
90
|
+
event: '$groupidentify',
|
|
91
|
+
properties: (common[:properties] || {}).merge(
|
|
92
|
+
'$group_type': group_type,
|
|
93
|
+
'$group_key': group_key,
|
|
94
|
+
'$group_set': properties
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# In addition to the common fields, alias accepts:
|
|
101
|
+
#
|
|
102
|
+
# - "alias"
|
|
103
|
+
# @param fields [Hash]
|
|
104
|
+
# @return [Hash]
|
|
105
|
+
def parse_for_alias(fields)
|
|
106
|
+
common = parse_common_fields(fields)
|
|
107
|
+
|
|
108
|
+
distinct_id = common[:distinct_id] # must both be set and move to properties
|
|
109
|
+
|
|
110
|
+
alias_field = fields[:alias]
|
|
111
|
+
check_presence! alias_field, 'alias'
|
|
112
|
+
|
|
113
|
+
common.merge(
|
|
114
|
+
{
|
|
115
|
+
type: 'alias',
|
|
116
|
+
event: '$create_alias',
|
|
117
|
+
distinct_id: distinct_id,
|
|
118
|
+
properties:
|
|
119
|
+
{ distinct_id: distinct_id, alias: alias_field }.merge(
|
|
120
|
+
common[:properties] || {}
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Common fields are:
|
|
129
|
+
#
|
|
130
|
+
# - "timestamp"
|
|
131
|
+
# - "distinct_id"
|
|
132
|
+
# - "message_id" (deprecated; normalized to "uuid" when it is a valid UUID)
|
|
133
|
+
# - "send_feature_flags"
|
|
134
|
+
#
|
|
135
|
+
# A new "uuid" is generated when neither "uuid" nor "message_id" is valid.
|
|
136
|
+
def parse_common_fields(fields)
|
|
137
|
+
timestamp = fields[:timestamp] || Time.new
|
|
138
|
+
distinct_id = fields[:distinct_id]
|
|
139
|
+
send_feature_flags = fields[:send_feature_flags]
|
|
140
|
+
|
|
141
|
+
check_timestamp! timestamp
|
|
142
|
+
check_presence! distinct_id, 'distinct_id'
|
|
143
|
+
|
|
144
|
+
is_server = fields.fetch(:is_server, true) != false
|
|
145
|
+
|
|
146
|
+
properties = {
|
|
147
|
+
'$lib' => fields.fetch(:lib, 'txlemetry-ruby'),
|
|
148
|
+
'$lib_version' => fields.fetch(:lib_version, Txlemetry::VERSION.to_s)
|
|
149
|
+
}
|
|
150
|
+
properties['$is_server'] = true if is_server
|
|
151
|
+
|
|
152
|
+
parsed = {
|
|
153
|
+
timestamp: time_in_iso8601(timestamp.getutc),
|
|
154
|
+
distinct_id: distinct_id,
|
|
155
|
+
properties: properties
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
parsed['uuid'] = normalized_uuid(fields)
|
|
159
|
+
|
|
160
|
+
if send_feature_flags && fields[:feature_variants]
|
|
161
|
+
feature_variants = fields[:feature_variants]
|
|
162
|
+
active_feature_variants = {}
|
|
163
|
+
feature_variants.each do |key, value|
|
|
164
|
+
parsed[:properties]["$feature/#{key}"] = value
|
|
165
|
+
active_feature_variants[key] = value if value != false
|
|
166
|
+
end
|
|
167
|
+
parsed[:properties]['$active_feature_flags'] = active_feature_variants.keys
|
|
168
|
+
end
|
|
169
|
+
parsed
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def check_timestamp!(timestamp)
|
|
173
|
+
return if timestamp.is_a? Time
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, 'Timestamp must be a Time'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# private: Ensures that a string is non-empty
|
|
179
|
+
#
|
|
180
|
+
# obj - String|Number that must be not blank
|
|
181
|
+
# name - The name of the validated value
|
|
182
|
+
def check_presence!(obj, name)
|
|
183
|
+
return unless obj.nil? || (obj.is_a?(String) && obj.empty?)
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "#{name} must be given"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def check_is_hash!(obj, name)
|
|
189
|
+
raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def normalized_uuid(fields)
|
|
193
|
+
uuid = fields[:uuid]
|
|
194
|
+
return uuid if uuid && valid_uuid_for_event_props?(uuid)
|
|
195
|
+
|
|
196
|
+
message_id = fields[:message_id]
|
|
197
|
+
return message_id if message_id && valid_uuid_for_event_props?(message_id)
|
|
198
|
+
|
|
199
|
+
SecureRandom.uuid
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param [Object] uuid - the UUID to validate, user provided, so we don't know the type
|
|
203
|
+
# @return [TrueClass, FalseClass] - true if the UUID is valid or absent, false otherwise
|
|
204
|
+
def valid_uuid_for_event_props?(uuid)
|
|
205
|
+
return true if uuid.nil?
|
|
206
|
+
|
|
207
|
+
unless uuid.is_a?(String)
|
|
208
|
+
logger.warn 'UUID is not a string. Ignoring it.'
|
|
209
|
+
return false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
is_valid_uuid = uuid.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
|
213
|
+
logger.warn "UUID is not valid: #{uuid}. Ignoring it." unless is_valid_uuid
|
|
214
|
+
|
|
215
|
+
is_valid_uuid
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txlemetry
|
|
4
|
+
# Interface for external caching of feature flag definitions.
|
|
5
|
+
#
|
|
6
|
+
# Enables multi-worker environments (Kubernetes, load-balanced servers,
|
|
7
|
+
# serverless functions) to share flag definitions via an external cache,
|
|
8
|
+
# reducing redundant API calls.
|
|
9
|
+
#
|
|
10
|
+
# Implement the four required methods on any object and pass it as the
|
|
11
|
+
# +:flag_definition_cache_provider+ option when creating a {Client}.
|
|
12
|
+
#
|
|
13
|
+
# == Required Methods
|
|
14
|
+
#
|
|
15
|
+
# @!method flag_definitions
|
|
16
|
+
# Retrieve cached flag definitions. Return a Hash with +:flags+,
|
|
17
|
+
# +:group_type_mapping+, +:cohorts+, +:minimal_flag_called_events+, and +:property_matching_version+
|
|
18
|
+
# keys, or +nil+ if the cache is empty. Returning +nil+ triggers an API
|
|
19
|
+
# fetch when no flags are loaded yet (emergency fallback). Providers
|
|
20
|
+
# written before +:minimal_flag_called_events+ existed continue to work;
|
|
21
|
+
# a missing key is treated as +false+. Preserve +:property_matching_version+
|
|
22
|
+
# with the definitions: exactly +2+ selects explicit equality; missing/1
|
|
23
|
+
# (including older cache entries) selects service legacy boolean matching.
|
|
24
|
+
# A fresh entry without the version resets to legacy, even after version 2.
|
|
25
|
+
# @return [Hash, nil]
|
|
26
|
+
#
|
|
27
|
+
# @!method should_fetch_flag_definitions?
|
|
28
|
+
# Return +true+ if this instance should fetch new definitions from the
|
|
29
|
+
# API, +false+ to read from cache instead. Use for distributed lock
|
|
30
|
+
# coordination so only one worker fetches at a time.
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
#
|
|
33
|
+
# @!method on_flag_definitions_received(data)
|
|
34
|
+
# Called after successfully fetching new definitions from the API.
|
|
35
|
+
# +data+ is a Hash with +:flags+, +:group_type_mapping+, +:cohorts+,
|
|
36
|
+
# +:minimal_flag_called_events+, and +:property_matching_version+ keys
|
|
37
|
+
# (plain Ruby types, not Concurrent:: wrappers). Store the entire snapshot
|
|
38
|
+
# together in your external cache, including version-only updates.
|
|
39
|
+
# @param data [Hash]
|
|
40
|
+
# @return [void]
|
|
41
|
+
#
|
|
42
|
+
# @!method shutdown
|
|
43
|
+
# Called when the Txlemetry client shuts down. Release any distributed
|
|
44
|
+
# locks and clean up resources.
|
|
45
|
+
# @return [void]
|
|
46
|
+
#
|
|
47
|
+
# == Error Handling
|
|
48
|
+
#
|
|
49
|
+
# All methods are wrapped in +begin/rescue+. Errors are logged but never
|
|
50
|
+
# break flag evaluation:
|
|
51
|
+
# - +should_fetch_flag_definitions?+ errors default to fetching (fail-safe)
|
|
52
|
+
# - +flag_definitions+ errors fall back to API fetch
|
|
53
|
+
# - +on_flag_definitions_received+ errors are logged; flags remain in memory
|
|
54
|
+
# - +shutdown+ errors are logged; shutdown continues
|
|
55
|
+
#
|
|
56
|
+
# == Example
|
|
57
|
+
#
|
|
58
|
+
# cache = RedisFlagCache.new(redis, service_key: 'my-service')
|
|
59
|
+
# client = Txlemetry::Client.new(
|
|
60
|
+
# api_key: '<project_api_key>',
|
|
61
|
+
# secret_key: '<secret_key>',
|
|
62
|
+
# flag_definition_cache_provider: cache
|
|
63
|
+
# )
|
|
64
|
+
#
|
|
65
|
+
module FlagDefinitionCacheProvider
|
|
66
|
+
REQUIRED_METHODS = %i[
|
|
67
|
+
flag_definitions
|
|
68
|
+
should_fetch_flag_definitions?
|
|
69
|
+
on_flag_definitions_received
|
|
70
|
+
shutdown
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
73
|
+
# Validates that +provider+ implements all required methods.
|
|
74
|
+
# Raises +ArgumentError+ listing any missing methods.
|
|
75
|
+
#
|
|
76
|
+
# @param provider [Object] the cache provider to validate
|
|
77
|
+
# @raise [ArgumentError] if any required methods are missing
|
|
78
|
+
# @return [void]
|
|
79
|
+
def self.validate!(provider)
|
|
80
|
+
missing = REQUIRED_METHODS.reject { |m| provider.respond_to?(m) }
|
|
81
|
+
return if missing.empty?
|
|
82
|
+
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
"Flag definition cache provider is missing required methods: #{missing.join(', ')}. " \
|
|
85
|
+
'See Txlemetry::FlagDefinitionCacheProvider for the required interface.'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txlemetry
|
|
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 txlemetry-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 = :txlemetry_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,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Txlemetry
|
|
6
|
+
# Wraps an existing logger and adds a prefix to all messages.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class PrefixedLogger
|
|
10
|
+
# @param logger [Logger, #debug, #info, #warn, #error]
|
|
11
|
+
# @param prefix [String]
|
|
12
|
+
def initialize(logger, prefix)
|
|
13
|
+
@logger = logger
|
|
14
|
+
@prefix = prefix
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def debug(msg)
|
|
18
|
+
@logger.debug("#{@prefix} #{msg}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def info(msg)
|
|
22
|
+
@logger.info("#{@prefix} #{msg}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warn(msg)
|
|
26
|
+
@logger.warn("#{@prefix} #{msg}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(msg)
|
|
30
|
+
@logger.error("#{@prefix} #{msg}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def level=(severity)
|
|
34
|
+
@logger.level = severity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def level
|
|
38
|
+
@logger.level
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module Logging
|
|
43
|
+
class << self
|
|
44
|
+
# @return [Logger, Txlemetry::PrefixedLogger] The logger used by the SDK.
|
|
45
|
+
def logger
|
|
46
|
+
return @logger if @logger
|
|
47
|
+
|
|
48
|
+
base_logger =
|
|
49
|
+
if defined?(::Rails)
|
|
50
|
+
::Rails.logger
|
|
51
|
+
else
|
|
52
|
+
logger = Logger.new $stdout
|
|
53
|
+
logger.progname = 'Txlemetry'
|
|
54
|
+
logger.level = Logger::WARN
|
|
55
|
+
logger
|
|
56
|
+
end
|
|
57
|
+
@logger = PrefixedLogger.new(base_logger, '[txlemetry-ruby]')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param logger [Logger, #debug, #info, #warn, #error] Custom logger used by the SDK.
|
|
61
|
+
attr_writer :logger
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.included(base)
|
|
65
|
+
class << base
|
|
66
|
+
def logger
|
|
67
|
+
Logging.logger
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Logger, Txlemetry::PrefixedLogger] The logger used by the SDK.
|
|
73
|
+
def logger
|
|
74
|
+
Logging.logger
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Txlemetry MCP analytics for Ruby
|
|
2
|
+
|
|
3
|
+
> **Experimental and unsupported.** `Txlemetry::MCP` is not an officially supported Txlemetry SDK: no
|
|
4
|
+
> support is provided for it, and its API, options, and the captured `$mcp_*` event schema may change
|
|
5
|
+
> in a minor release. A one-line warning is logged when you require it. Bug reports and patches are
|
|
6
|
+
> welcome at https://github.com/Txlemetry/txlemetry-ruby/issues, but don't build production reporting on
|
|
7
|
+
> it yet.
|
|
8
|
+
|
|
9
|
+
Product analytics for [Model Context Protocol](https://modelcontextprotocol.io) servers built on the
|
|
10
|
+
official Ruby [`mcp`](https://rubygems.org/gems/mcp) gem. Wrap an `MCP::Server` so every tool call,
|
|
11
|
+
agent intent, handshake, listing, prompt, resource read, and failure is captured to Txlemetry.
|
|
12
|
+
|
|
13
|
+
**Documentation: https://txlemetry.com/docs/mcp-analytics** — setup, every option, the event and
|
|
14
|
+
property catalog, sessions on stateless/multi-pod servers, conversation ids, intent, identifying
|
|
15
|
+
users, privacy, and custom dispatchers. That's the single source of truth; this directory
|
|
16
|
+
deliberately keeps no second copy of it.
|
|
17
|
+
|
|
18
|
+
Install is just `gem 'txlemetry-ruby'`. `Txlemetry::MCP.instrument` needs the `mcp` gem (`>= 1.4`) at
|
|
19
|
+
runtime, but anyone wrapping a server already has it. `Txlemetry::MCP::Client` (custom dispatchers)
|
|
20
|
+
needs nothing beyond `txlemetry-ruby`.
|
|
21
|
+
|
|
22
|
+
## Ruby-specific notes
|
|
23
|
+
|
|
24
|
+
These are the few things that differ from what the docs describe for the other SDKs.
|
|
25
|
+
|
|
26
|
+
* **`$lib` is per event.** MCP events report `$lib: "txlemetry-ruby-mcp"` while the client you pass in
|
|
27
|
+
keeps its own identity (`txlemetry-ruby` or `txlemetry-rails`) for everything else it sends, so
|
|
28
|
+
instrumenting a server inside a Rails app doesn't relabel the app's other events.
|
|
29
|
+
* **Truncation is tighter.** Events are truncated to fit the core client's 32KB per-message limit,
|
|
30
|
+
because txlemetry-ruby drops larger messages at batch time - truncating harder beats losing the
|
|
31
|
+
event.
|
|
32
|
+
* **Request scope needs Ruby 3.2+ to be inherited.** A custom event captured from a thread or fiber
|
|
33
|
+
a tool spawns is attributed to that request on 3.2+ (fiber storage); before 3.2 capture from the
|
|
34
|
+
tool body itself, or an HTTP server falls back to a standalone session.
|
|
35
|
+
* **Composed schemas are left alone.** A tool whose `input_schema` is `oneOf`/`allOf`/`anyOf` or a
|
|
36
|
+
`$ref` has nothing injected into it and nothing stripped from its calls.
|
|
37
|
+
* **stdio servers own `$stdout`.** The integration's own messages go only to the `logger:` you pass
|
|
38
|
+
(default: nowhere); the experimental notice and misconfiguration warnings go to stderr. Point the
|
|
39
|
+
core SDK's logger away from stdout too: `Txlemetry::Logging.logger = Logger.new($stderr)`.
|
|
40
|
+
|
|
41
|
+
## Layout
|
|
42
|
+
|
|
43
|
+
| File | What it does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `mcp.rb` (parent dir) | `Txlemetry::MCP.instrument` and the public helpers |
|
|
46
|
+
| `server_extension.rb` | Prepended onto `MCP::Server` and the Streamable HTTP transport |
|
|
47
|
+
| `instrumentation.rb` | Per-request dispatch: intent, identity, sessions, recording |
|
|
48
|
+
| `client.rb` | `Txlemetry::MCP::Client` for custom dispatchers |
|
|
49
|
+
| `rack_middleware.rb` | `Mcp-Session-Id` tokens for a custom Rack stack |
|
|
50
|
+
| `sanitization.rb`, `truncation.rb` | Redaction and payload budgets |
|
|
51
|
+
|
|
52
|
+
Runnable example: [`examples/mcp_server.rb`](../../../examples/mcp_server.rb). Specs:
|
|
53
|
+
`spec/txlemetry/mcp/`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txlemetry
|
|
4
|
+
module MCP
|
|
5
|
+
# Handle returned by {Txlemetry::MCP.instrument}. Emits custom events onto the
|
|
6
|
+
# same pipeline as the auto-captured `$mcp_*` events.
|
|
7
|
+
#
|
|
8
|
+
# @note Experimental.
|
|
9
|
+
class Analytics
|
|
10
|
+
# @api private
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Capture a custom event scoped to the current MCP session. The event
|
|
16
|
+
# name is sent verbatim (a customer event, not `$`-prefixed).
|
|
17
|
+
#
|
|
18
|
+
# Inside a tool body the session and the identity are the ones pinned to
|
|
19
|
+
# the in-flight request by {Instrumentation}, so a concurrent request on
|
|
20
|
+
# the same session cannot reattribute this event. Over stdio, where a
|
|
21
|
+
# server only ever talks to one client, the session is the server's
|
|
22
|
+
# current one. On an HTTP server a call that has lost the request scope
|
|
23
|
+
# gets a standalone session rather than the server's, which may belong to
|
|
24
|
+
# another caller's request.
|
|
25
|
+
#
|
|
26
|
+
# @param event [String] event name
|
|
27
|
+
# @param properties [Hash] event properties
|
|
28
|
+
# @return [void]
|
|
29
|
+
# @raise [ArgumentError] when the event name is blank
|
|
30
|
+
def capture(event, properties = {})
|
|
31
|
+
unless event.is_a?(String) && !event.strip.empty?
|
|
32
|
+
raise ArgumentError, 'capture() requires an event name, e.g. analytics.capture("feedback_submitted")'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
data = Txlemetry::MCP.tracking_data(@server)
|
|
36
|
+
return if data.nil?
|
|
37
|
+
|
|
38
|
+
scope = RequestScope.current
|
|
39
|
+
scope = nil unless scope.is_a?(Hash)
|
|
40
|
+
Instrumentation.capture_event(data, {
|
|
41
|
+
'session_id' => current_session_id(data, scope),
|
|
42
|
+
'event_type' => EventType::CUSTOM,
|
|
43
|
+
'event_name' => event,
|
|
44
|
+
'timestamp' => Time.now.utc,
|
|
45
|
+
'properties' => properties
|
|
46
|
+
}, actor: scoped_actor(scope))
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Flush the underlying Txlemetry client.
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
def flush
|
|
54
|
+
data = Txlemetry::MCP.tracking_data(@server)
|
|
55
|
+
client = data&.sink&.client
|
|
56
|
+
client.flush if client.respond_to?(:flush)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The session pinned to the in-flight request, when there is one. On a server
|
|
63
|
+
# that has served an HTTP request the server-wide session is not a safe
|
|
64
|
+
# fallback: it belongs to whichever request settled it last, which under
|
|
65
|
+
# concurrency is somebody else's. That happens when a tool hands its work to
|
|
66
|
+
# a thread or fiber it spawned on Ruby 3.0/3.1, where {RequestScope} is
|
|
67
|
+
# fiber-local and is not inherited. Fail closed with a standalone session
|
|
68
|
+
# rather than filing the event under another caller's identity; over stdio a
|
|
69
|
+
# server only ever talks to one client, so the fallback stays.
|
|
70
|
+
def current_session_id(data, scope)
|
|
71
|
+
scoped = scope ? scope[:session_id] : nil
|
|
72
|
+
return scoped if scoped
|
|
73
|
+
return data.session_id unless data.http_transport_seen
|
|
74
|
+
|
|
75
|
+
warn_unscoped_capture(data)
|
|
76
|
+
Session.new_session_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The identity the in-flight request resolved, which {Instrumentation} pinned
|
|
80
|
+
# on the scope before running the tool body. Absent outside a request it
|
|
81
|
+
# started, and only then is the session-keyed cache consulted instead.
|
|
82
|
+
def scoped_actor(scope)
|
|
83
|
+
scope&.key?(:actor) ? scope[:actor] : Instrumentation::UNRESOLVED_ACTOR
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def warn_unscoped_capture(data)
|
|
87
|
+
return if data.warned_unscoped_capture
|
|
88
|
+
|
|
89
|
+
data.warned_unscoped_capture = true
|
|
90
|
+
Log.warn(
|
|
91
|
+
data.options,
|
|
92
|
+
'Warning: analytics.capture() ran without the scope of the request that started it, so its event got ' \
|
|
93
|
+
'a standalone $session_id instead of the caller\'s. On Ruby 3.2+ the scope follows threads and fibers ' \
|
|
94
|
+
'a tool spawns; before that it does not, so capture custom events from the tool body itself.'
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returned when instrumentation could not be set up; every call is a no-op.
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
102
|
+
class NoopAnalytics < Analytics
|
|
103
|
+
def initialize
|
|
104
|
+
super(nil)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def capture(_event = nil, _properties = {})
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def flush
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|