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,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
|