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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module MCP
5
+ # A {Txlemetry::Client} with first-class MCP analytics for custom dispatchers
6
+ # (your own HTTP layer, no `MCP::Server` to wrap). The host resolves
7
+ # identity and context per request and calls the capture methods directly;
8
+ # events flow through the same sanitize -> truncate -> `$exception` fan-out
9
+ # pipeline as {Txlemetry::MCP.instrument}. Does not need the `mcp` gem.
10
+ #
11
+ # @note Experimental.
12
+ #
13
+ # @example
14
+ # txlemetry = Txlemetry::MCP::Client.new(api_key: 'phc_...', host: 'https://app.txlemetry.com')
15
+ # txlemetry.capture_tool_call('search_docs', duration_ms: 42, distinct_id: 'user_123')
16
+ class Client < Txlemetry::Client
17
+ # @param opts [Hash] {Txlemetry::Client} options plus:
18
+ # @option opts [String] :missing_capability_tool_name name of the virtual tool (default `get_more_tools`)
19
+ # @option opts [Boolean] :mcp_exception_autocapture emit a sibling `$exception` for failed calls (default true)
20
+ # @option opts [Boolean, ModelOptions] :capture_model capture the calling model (default true)
21
+ def initialize(opts = {})
22
+ opts = opts.transform_keys(&:to_sym)
23
+ @missing_capability_tool_name = opts.delete(:missing_capability_tool_name) || Tools::GET_MORE_TOOLS_NAME
24
+ @mcp_exception_autocapture = opts.delete(:mcp_exception_autocapture) != false
25
+ capture_model = opts.key?(:capture_model) ? opts.delete(:capture_model) : true
26
+ @capture_model_option = Options.new(capture_model: capture_model).capture_model
27
+ super
28
+ @mcp_sink = Sink.new(self)
29
+ @mcp_options = Options.new(
30
+ enable_exception_autocapture: @mcp_exception_autocapture,
31
+ missing_capability_tool_name: @missing_capability_tool_name
32
+ )
33
+ end
34
+
35
+ # Capture a tool invocation. Emits `$mcp_tool_call` (+ `$exception` on error).
36
+ #
37
+ # @return [void]
38
+ def capture_tool_call(tool_name, intent: nil, intent_source: nil, parameters: nil, response: nil,
39
+ duration_ms: nil, is_error: false, error: nil, error_type: nil, category: nil,
40
+ tool_description: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
41
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
42
+ properties: nil, timestamp: nil, llm_model: nil, llm_model_source: nil)
43
+ event = base_event(EventType::MCP_TOOLS_CALL, distinct_id, session_id, set_properties, groups, properties,
44
+ timestamp, client_user_agent, vendor_client)
45
+ event['resource_name'] = tool_name
46
+ event['tool_description'] = tool_description
47
+ event['tool_category'] = category
48
+ event['protocol_version'] = protocol_version
49
+ event['parameters'] = parameters
50
+ event['response'] = response
51
+ event['duration'] = duration_ms
52
+ event['is_error'] = is_error == true
53
+ event['error_type'] = error_type
54
+ apply_intent(event, intent, intent_source)
55
+ apply_model(event, llm_model, llm_model_source)
56
+ if is_error
57
+ event['error'] =
58
+ Exceptions.capture_exception(error.nil? ? "Tool #{tool_name} returned an error" : error)
59
+ end
60
+ emit(event)
61
+ end
62
+
63
+ # Capture the connection handshake. Emits `$mcp_initialize`.
64
+ #
65
+ # @return [void]
66
+ def capture_initialize(client_name: nil, client_version: nil, protocol_version: nil, parameters: nil,
67
+ response: nil, duration_ms: nil, distinct_id: nil, session_id: nil,
68
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
69
+ properties: nil, timestamp: nil)
70
+ event = base_event(EventType::MCP_INITIALIZE, distinct_id, session_id, set_properties, groups, properties,
71
+ timestamp, client_user_agent, vendor_client)
72
+ event['client_name'] = client_name
73
+ event['client_version'] = client_version
74
+ event['protocol_version'] = protocol_version
75
+ event['parameters'] = parameters
76
+ event['response'] = response
77
+ event['duration'] = duration_ms
78
+ emit(event)
79
+ end
80
+
81
+ # Capture a `tools/list` response. Emits `$mcp_tools_list` with `$mcp_listed_tool_names`.
82
+ #
83
+ # @return [void]
84
+ def capture_tools_list(tool_names: nil, parameters: nil, response: nil, duration_ms: nil, is_error: false,
85
+ error: nil, error_type: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
86
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
87
+ properties: nil, timestamp: nil)
88
+ event = base_event(EventType::MCP_TOOLS_LIST, distinct_id, session_id, set_properties, groups, properties,
89
+ timestamp, client_user_agent, vendor_client)
90
+ event['listed_tool_names'] = tool_names
91
+ event['protocol_version'] = protocol_version
92
+ event['parameters'] = parameters
93
+ event['response'] = response
94
+ event['duration'] = duration_ms
95
+ event['is_error'] = is_error == true
96
+ event['error_type'] = error_type
97
+ event['error'] = Exceptions.capture_exception(error.nil? ? 'tools/list failed' : error) if is_error
98
+ emit(event)
99
+ end
100
+
101
+ # Capture a `get_more_tools` call as a missing-capability report. Emits
102
+ # `$mcp_missing_capability` with the agent's description as `$mcp_intent`.
103
+ #
104
+ # @return [void]
105
+ def capture_missing_capability(context: nil, parameters: nil, protocol_version: nil, distinct_id: nil,
106
+ session_id: nil, client_user_agent: nil, vendor_client: nil,
107
+ set_properties: nil, groups: nil, properties: nil, timestamp: nil,
108
+ llm_model: nil, llm_model_source: nil)
109
+ event = base_event(EventType::MCP_MISSING_CAPABILITY, distinct_id, session_id, set_properties, groups,
110
+ properties, timestamp, client_user_agent, vendor_client)
111
+ event['resource_name'] = @missing_capability_tool_name
112
+ event['protocol_version'] = protocol_version
113
+ event['parameters'] = parameters
114
+ apply_intent(event, context, 'context_parameter')
115
+ apply_model(event, llm_model, llm_model_source)
116
+ emit(event)
117
+ end
118
+
119
+ # Inject the `context` and `llm_model` arguments into
120
+ # every tool descriptor (Hash with `inputSchema`) so agents state their intent,
121
+ # and optionally append the `get_more_tools` virtual tool. Returns a new Array
122
+ # of new Hashes. A tool whose schema is composed (oneOf/allOf/anyOf) or a
123
+ # `$ref` is passed through untouched. Pass `capture_model: false` here or
124
+ # to {#initialize} to disable model capture for this client, including
125
+ # extraction from later tool calls.
126
+ #
127
+ # @param tools [Array<Hash>] `tools/list` entries
128
+ # @return [Array<Hash>]
129
+ def prepare_tool_list(tools, context: true, report_missing: false, capture_model: @capture_model_option)
130
+ options = Options.new(context: context, capture_model: capture_model)
131
+ @capture_model_option = false unless options.capture_model_enabled?
132
+ options = Options.new(context: context, capture_model: false) if @capture_model_option == false
133
+ prepared = tools.map do |tool|
134
+ next tool unless tool.is_a?(Hash) && (options.context_enabled? || options.capture_model_enabled?)
135
+
136
+ name = SchemaMutation.fetch(tool, :name) || 'unknown'
137
+ next tool if name == @missing_capability_tool_name
138
+
139
+ schema = SchemaMutation.fetch(tool, :inputSchema)
140
+ if options.context_enabled?
141
+ schema = SchemaMutation.add_context_parameter(schema, tool_name: name,
142
+ description: options.context_description)
143
+ end
144
+ if options.capture_model_enabled?
145
+ schema = SchemaMutation.add_model_parameter(schema, tool_name: name,
146
+ description: options.model_description)
147
+ end
148
+ tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema)
149
+ end
150
+ if report_missing && prepared.none? { |t| SchemaMutation.fetch(t, :name) == @missing_capability_tool_name }
151
+ prepared << Tools.descriptor(@missing_capability_tool_name, options)
152
+ end
153
+ prepared
154
+ end
155
+
156
+ # Pull the agent's intent off the `context` argument and its self-reported
157
+ # model off `llm_model`, strip the arguments this integration injected, and
158
+ # flag the `get_more_tools` virtual tool. Hand `intent`/`intent_source` and
159
+ # `llm_model`/`llm_model_source` straight to {#capture_tool_call}.
160
+ #
161
+ # Pass the tool's own `inputSchema` (the same Hash you handed to
162
+ # {#prepare_tool_list}) so a field the tool declares itself is left in
163
+ # `args` and never read as analytics: only an injected argument is stripped
164
+ # and reported. A composed (oneOf/allOf/anyOf) or `$ref` schema is never
165
+ # injected into, so its fields are the tool's own and are left alone too.
166
+ # Without the schema there is no way to tell the two apart, so the injected
167
+ # names are always stripped.
168
+ #
169
+ # @param name [String] tool name
170
+ # @param args [Hash, nil] the call's arguments
171
+ # @param input_schema [Hash, nil] the tool's raw `inputSchema`
172
+ # @return [PreparedToolCall]
173
+ def prepare_tool_call(name, args = nil, input_schema: nil)
174
+ intent = tool_declares?(input_schema, 'context') ? nil : Intent.normalize(argument(args, 'context'))
175
+ model = if @capture_model_option == false || tool_declares?(input_schema, ModelCapture::PARAM_NAME)
176
+ nil
177
+ else
178
+ ModelCapture.normalize(argument(args, ModelCapture::PARAM_NAME))
179
+ end
180
+ PreparedToolCall.new(
181
+ args: strip_injected(args, input_schema),
182
+ intent: intent,
183
+ intent_source: intent ? 'context_parameter' : nil,
184
+ llm_model: model,
185
+ llm_model_source: model ? 'self_reported' : nil,
186
+ is_missing_capability: name == @missing_capability_tool_name
187
+ )
188
+ end
189
+
190
+ private
191
+
192
+ def base_event(event_type, distinct_id, session_id, set_properties, groups, properties, timestamp,
193
+ client_user_agent, vendor_client)
194
+ event = {
195
+ 'event_type' => event_type,
196
+ 'session_id' => session_id,
197
+ 'timestamp' => timestamp || Time.now.utc,
198
+ 'properties' => properties,
199
+ 'groups' => groups,
200
+ 'client_user_agent' => client_user_agent,
201
+ 'vendor_client' => vendor_client
202
+ }
203
+ event['identify_actor_given_id'] = distinct_id if distinct_id.is_a?(String) && !distinct_id.empty?
204
+ event['identify_actor_data'] = set_properties if set_properties.is_a?(Hash) && !set_properties.empty?
205
+ event
206
+ end
207
+
208
+ def apply_model(event, llm_model, source)
209
+ return if @capture_model_option == false
210
+
211
+ model = ModelCapture.normalize(llm_model)
212
+ return unless model
213
+
214
+ event['llm_model'] = model
215
+ event['llm_model_source'] = source || 'self_reported'
216
+ end
217
+
218
+ def apply_intent(event, intent, source)
219
+ trimmed = intent.is_a?(String) ? intent.strip : ''
220
+ return if trimmed.empty?
221
+
222
+ event['user_intent'] = trimmed
223
+ event['user_intent_source'] = source || 'context_parameter'
224
+ end
225
+
226
+ def emit(event)
227
+ @mcp_sink.capture(event, @mcp_options)
228
+ nil
229
+ end
230
+
231
+ # True when the argument belongs to the tool rather than to this layer: it
232
+ # declares the field itself, or its schema is one we never inject into.
233
+ def tool_declares?(input_schema, param)
234
+ return false unless input_schema
235
+
236
+ !SchemaMutation.injectable?(input_schema) || SchemaMutation.declares_param?(input_schema, param)
237
+ end
238
+
239
+ def argument(args, param)
240
+ return nil unless args.is_a?(Hash)
241
+
242
+ args[param.to_sym] || args[param]
243
+ end
244
+
245
+ def strip_injected(args, input_schema)
246
+ return args unless args.is_a?(Hash)
247
+
248
+ params = ['context']
249
+ params << ModelCapture::PARAM_NAME unless @capture_model_option == false
250
+ keys = params.reject { |param| tool_declares?(input_schema, param) }
251
+ .flat_map { |param| [param.to_sym, param] }
252
+ .select { |key| args.key?(key) }
253
+ keys.empty? ? args : args.except(*keys)
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module MCP
5
+ # Value of `$mcp_source` on every primary `$mcp_*` event.
6
+ SOURCE = 'txlemetry_mcp_analytics'
7
+
8
+ # `$lib` stamped on MCP analytics events (per event, never on the client).
9
+ LIB_NAME = 'txlemetry-ruby-mcp'
10
+
11
+ # Generated (in-memory) sessions roll over after this much inactivity.
12
+ INACTIVITY_TIMEOUT_MINUTES = 30
13
+
14
+ # Header carrying the transport session id, and our self-encoded token.
15
+ MCP_SESSION_HEADER = 'mcp-session-id'
16
+
17
+ # Description of the injected `context` argument.
18
+ DEFAULT_CONTEXT_PARAMETER_DESCRIPTION =
19
+ 'Explain in 15-25 words, in third person, why this tool is called and how it supports ' \
20
+ "the user's goal. For analytics only. You MUST describe only the abstract purpose of the " \
21
+ 'tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying ' \
22
+ 'information from the user request or tool results, including names, emails, phone numbers, ' \
23
+ 'IPs, IDs, or credentials. You MUST generalize specific entities into roles such as "a user", ' \
24
+ '"the customer", or "an account". Example: "Retrieving a customer\'s recent orders to ' \
25
+ 'investigate a billing issue and help support determine the appropriate resolution."'
26
+
27
+ # Description of the injected `llm_model` argument.
28
+ DEFAULT_MODEL_PARAMETER_DESCRIPTION =
29
+ 'The exact model identifier you (the assistant) are running as, taken from your system ' \
30
+ 'prompt or environment (e.g. "claude-opus-4-8", "gpt-5.2"). Used for analytics only. If you ' \
31
+ 'do not know your model identifier with certainty, pass "unknown" — never guess.'
32
+
33
+ # Description of the injected `conversation_id` argument.
34
+ DEFAULT_CONVERSATION_ID_DESCRIPTION =
35
+ "Echo the conversation_id from the server's previous response. The server provides it on " \
36
+ 'the first call — never invent one, and do not issue parallel tool calls until you have it.'
37
+
38
+ # Txlemetry-owned event names. All `$`-prefixed per the Txlemetry convention.
39
+ module Event
40
+ CUSTOM = '$mcp_custom'
41
+ EXCEPTION = '$exception'
42
+ IDENTIFY = '$identify'
43
+ INITIALIZE = '$mcp_initialize'
44
+ MISSING_CAPABILITY = '$mcp_missing_capability'
45
+ PROMPT_GET = '$mcp_prompt_get'
46
+ PROMPTS_LIST = '$mcp_prompts_list'
47
+ RESOURCE_READ = '$mcp_resource_read'
48
+ RESOURCES_LIST = '$mcp_resources_list'
49
+ TOOL_CALL = '$mcp_tool_call'
50
+ TOOLS_LIST = '$mcp_tools_list'
51
+ end
52
+
53
+ # Txlemetry property wire keys emitted on MCP events.
54
+ module Property
55
+ CLIENT_NAME = '$mcp_client_name'
56
+ CLIENT_USER_AGENT = '$mcp_client_user_agent'
57
+ CLIENT_VERSION = '$mcp_client_version'
58
+ VENDOR_CLIENT = '$mcp_vendor_client'
59
+ PROTOCOL_VERSION = '$mcp_protocol_version'
60
+ CONVERSATION_ID = '$mcp_conversation_id'
61
+ DURATION_MS = '$mcp_duration_ms'
62
+ ERROR_MESSAGE = '$mcp_error_message'
63
+ ERROR_TYPE = '$mcp_error_type'
64
+ IS_ERROR = '$mcp_is_error'
65
+ INTENT = '$mcp_intent'
66
+ INTENT_SOURCE = '$mcp_intent_source'
67
+ LISTED_TOOL_NAMES = '$mcp_listed_tool_names'
68
+ LLM_MODEL = '$mcp_llm_model'
69
+ LLM_MODEL_SOURCE = '$mcp_llm_model_source'
70
+ PARAMETERS = '$mcp_parameters'
71
+ RESOURCE_NAME = '$mcp_resource_name'
72
+ RESPONSE = '$mcp_response'
73
+ SERVER_NAME = '$mcp_server_name'
74
+ SERVER_VERSION = '$mcp_server_version'
75
+ SESSION_ID = '$session_id'
76
+ SOURCE = '$mcp_source'
77
+ TOOL_CATEGORY = '$mcp_tool_category'
78
+ TOOL_DESCRIPTION = '$mcp_tool_description'
79
+ TOOL_NAME = '$mcp_tool_name'
80
+ end
81
+
82
+ # Internal dispatch keys for the event pipeline (never sent on the wire).
83
+ #
84
+ # @api private
85
+ module EventType
86
+ CUSTOM = 'posthog:custom'
87
+ IDENTIFY = 'posthog:identify'
88
+ MCP_INITIALIZE = 'mcp:initialize'
89
+ MCP_MISSING_CAPABILITY = 'mcp:missing_capability'
90
+ MCP_PROMPTS_GET = 'mcp:prompts/get'
91
+ MCP_PROMPTS_LIST = 'mcp:prompts/list'
92
+ MCP_RESOURCES_LIST = 'mcp:resources/list'
93
+ MCP_RESOURCES_READ = 'mcp:resources/read'
94
+ MCP_TOOLS_CALL = 'mcp:tools/call'
95
+ MCP_TOOLS_LIST = 'mcp:tools/list'
96
+
97
+ EVENT_NAME_BY_TYPE = {
98
+ CUSTOM => Event::CUSTOM,
99
+ IDENTIFY => Event::IDENTIFY,
100
+ MCP_INITIALIZE => Event::INITIALIZE,
101
+ MCP_MISSING_CAPABILITY => Event::MISSING_CAPABILITY,
102
+ MCP_PROMPTS_GET => Event::PROMPT_GET,
103
+ MCP_PROMPTS_LIST => Event::PROMPTS_LIST,
104
+ MCP_RESOURCES_LIST => Event::RESOURCES_LIST,
105
+ MCP_RESOURCES_READ => Event::RESOURCE_READ,
106
+ MCP_TOOLS_CALL => Event::TOOL_CALL,
107
+ MCP_TOOLS_LIST => Event::TOOLS_LIST
108
+ }.freeze
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Txlemetry
6
+ module MCP
7
+ # Optional `conversation_id` loop-back. When enabled, the SDK injects a
8
+ # `conversation_id` parameter into every tool, mints one when the agent does
9
+ # not supply it, hands it back on the response, and captures it as
10
+ # `$mcp_conversation_id`, stitching calls across reconnects and pods.
11
+ #
12
+ # @api private
13
+ module ConversationId
14
+ PARAM_NAME = 'conversation_id'
15
+ MINTED_CONVERSATION_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
16
+
17
+ MCP_INSTRUCTIONS_KEY = '_mcp_instructions'
18
+ INSTRUCTIONS_FIELD_DESCRIPTION = 'Server-issued metadata for this conversation.'
19
+ CONVERSATION_ID_FIELD_DESCRIPTION = 'The server-issued conversation identifier.'
20
+
21
+ module_function
22
+
23
+ def normalize(value)
24
+ return nil unless value.is_a?(String)
25
+
26
+ trimmed = value.strip
27
+ trimmed.empty? ? nil : trimmed
28
+ end
29
+
30
+ def extract(args)
31
+ return nil unless args.is_a?(Hash)
32
+
33
+ normalize(args[PARAM_NAME] || args[PARAM_NAME.to_sym])
34
+ end
35
+
36
+ # @param supplied [String, nil] the `conversation_id` argument, and only
37
+ # when this layer owns it. A tool that declares `conversation_id` in its
38
+ # own schema is passed application data, which must not anchor analytics:
39
+ # two users sharing such a value would be stitched into one conversation.
40
+ # @return [Array(String, Boolean), Array(nil, false)] `[conversation_id, minted]`
41
+ def resolve(enabled, supplied, tool_name, missing_capability_tool_name)
42
+ return [nil, false] if !enabled || tool_name == missing_capability_tool_name
43
+
44
+ value = normalize(supplied)
45
+ return [value.downcase, false] if value && MINTED_CONVERSATION_ID.match?(value)
46
+
47
+ [Ids.uuid_v7, true]
48
+ end
49
+
50
+ def prompt_back?(result)
51
+ result.is_a?(Hash) && (result[:content] || result['content']).is_a?(Array)
52
+ end
53
+
54
+ # Plain data, not an instruction: an instruction-shaped block is what a
55
+ # client's prompt-injection filter strips. Compact JSON.
56
+ def build_prompt_back(conversation_id)
57
+ { type: 'text', text: JSON.generate({ conversation_id: conversation_id }) }
58
+ end
59
+
60
+ # @return [Hash] a new result with the prompt-back appended (or the input unchanged)
61
+ def inject_prompt_back(result, conversation_id)
62
+ return result unless prompt_back?(result)
63
+
64
+ key = result.key?(:content) ? :content : 'content'
65
+ result.merge(key => result[key] + [build_prompt_back(conversation_id)])
66
+ end
67
+
68
+ # Mirror the handle into `structuredContent` for tools whose output schema
69
+ # declared `_mcp_instructions`. Customer data wins when the key exists.
70
+ #
71
+ # @return [Array(Object, Boolean)] `[result, delivered]`
72
+ def mirror_instructions(result, conversation_id)
73
+ return [result, false] unless result.is_a?(Hash)
74
+
75
+ key = %i[structuredContent structured_content].find { |k| result.key?(k) } ||
76
+ %w[structuredContent structured_content].find { |k| result.key?(k) }
77
+ return [result, false] if key.nil?
78
+
79
+ structured = result[key]
80
+ return [result, false] unless structured.is_a?(Hash)
81
+ return [result, false] if structured.key?(MCP_INSTRUCTIONS_KEY) || structured.key?(MCP_INSTRUCTIONS_KEY.to_sym)
82
+
83
+ payload = { 'conversation_id' => conversation_id }
84
+ instructions_key = structured.keys.first.is_a?(Symbol) ? MCP_INSTRUCTIONS_KEY.to_sym : MCP_INSTRUCTIONS_KEY
85
+ [result.merge(key => structured.merge(instructions_key => payload)), true]
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module MCP
5
+ # Translates a processed internal event (string keys) into one or two
6
+ # Txlemetry 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