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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +48 -0
  3. data/NOTICE +5 -0
  4. data/bin/txlemetry +110 -0
  5. data/lib/txlemetry/backoff_policy.rb +49 -0
  6. data/lib/txlemetry/client.rb +1266 -0
  7. data/lib/txlemetry/defaults.rb +50 -0
  8. data/lib/txlemetry/exception_capture.rb +246 -0
  9. data/lib/txlemetry/feature_flag.rb +85 -0
  10. data/lib/txlemetry/feature_flag_error.rb +37 -0
  11. data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
  12. data/lib/txlemetry/feature_flag_result.rb +81 -0
  13. data/lib/txlemetry/feature_flags.rb +1516 -0
  14. data/lib/txlemetry/field_parser.rb +219 -0
  15. data/lib/txlemetry/flag_definition_cache.rb +88 -0
  16. data/lib/txlemetry/internal/context.rb +119 -0
  17. data/lib/txlemetry/logging.rb +77 -0
  18. data/lib/txlemetry/mcp/README.md +53 -0
  19. data/lib/txlemetry/mcp/analytics.rb +116 -0
  20. data/lib/txlemetry/mcp/client.rb +257 -0
  21. data/lib/txlemetry/mcp/constants.rb +111 -0
  22. data/lib/txlemetry/mcp/conversation_id.rb +89 -0
  23. data/lib/txlemetry/mcp/event_builder.rb +168 -0
  24. data/lib/txlemetry/mcp/exceptions.rb +112 -0
  25. data/lib/txlemetry/mcp/identity.rb +194 -0
  26. data/lib/txlemetry/mcp/ids.rb +56 -0
  27. data/lib/txlemetry/mcp/instrumentation.rb +736 -0
  28. data/lib/txlemetry/mcp/intent.rb +101 -0
  29. data/lib/txlemetry/mcp/log.rb +37 -0
  30. data/lib/txlemetry/mcp/options.rb +128 -0
  31. data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
  32. data/lib/txlemetry/mcp/request_scope.rb +72 -0
  33. data/lib/txlemetry/mcp/sanitization.rb +504 -0
  34. data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
  35. data/lib/txlemetry/mcp/server_extension.rb +54 -0
  36. data/lib/txlemetry/mcp/session.rb +71 -0
  37. data/lib/txlemetry/mcp/session_token.rb +106 -0
  38. data/lib/txlemetry/mcp/sink.rb +98 -0
  39. data/lib/txlemetry/mcp/tools.rb +91 -0
  40. data/lib/txlemetry/mcp/tracking_data.rb +89 -0
  41. data/lib/txlemetry/mcp/truncation.rb +326 -0
  42. data/lib/txlemetry/mcp.rb +224 -0
  43. data/lib/txlemetry/message_batch.rb +80 -0
  44. data/lib/txlemetry/noop_worker.rb +39 -0
  45. data/lib/txlemetry/response.rb +17 -0
  46. data/lib/txlemetry/send_feature_flags_options.rb +49 -0
  47. data/lib/txlemetry/send_worker.rb +218 -0
  48. data/lib/txlemetry/transport.rb +216 -0
  49. data/lib/txlemetry/utils.rb +183 -0
  50. data/lib/txlemetry/version.rb +5 -0
  51. data/lib/txlemetry.rb +16 -0
  52. 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