language-operator 0.1.57 → 0.1.59

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/lib/language_operator/agent/base.rb +19 -0
  4. data/lib/language_operator/agent/executor.rb +11 -0
  5. data/lib/language_operator/agent/task_executor.rb +77 -22
  6. data/lib/language_operator/agent/telemetry.rb +22 -11
  7. data/lib/language_operator/agent.rb +3 -0
  8. data/lib/language_operator/cli/base_command.rb +7 -1
  9. data/lib/language_operator/cli/commands/agent.rb +578 -1
  10. data/lib/language_operator/cli/formatters/optimization_formatter.rb +226 -0
  11. data/lib/language_operator/cli/formatters/progress_formatter.rb +1 -1
  12. data/lib/language_operator/client/base.rb +72 -2
  13. data/lib/language_operator/client/mcp_connector.rb +28 -6
  14. data/lib/language_operator/instrumentation/task_tracer.rb +64 -2
  15. data/lib/language_operator/kubernetes/resource_builder.rb +3 -1
  16. data/lib/language_operator/learning/adapters/base_adapter.rb +147 -0
  17. data/lib/language_operator/learning/adapters/jaeger_adapter.rb +218 -0
  18. data/lib/language_operator/learning/adapters/signoz_adapter.rb +432 -0
  19. data/lib/language_operator/learning/adapters/tempo_adapter.rb +236 -0
  20. data/lib/language_operator/learning/optimizer.rb +318 -0
  21. data/lib/language_operator/learning/pattern_detector.rb +260 -0
  22. data/lib/language_operator/learning/task_synthesizer.rb +261 -0
  23. data/lib/language_operator/learning/trace_analyzer.rb +280 -0
  24. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  25. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  26. data/lib/language_operator/templates/task_synthesis.tmpl +97 -0
  27. data/lib/language_operator/tool_loader.rb +5 -3
  28. data/lib/language_operator/ux/concerns/provider_helpers.rb +2 -2
  29. data/lib/language_operator/version.rb +1 -1
  30. data/synth/003/Makefile +10 -0
  31. data/synth/003/output.log +68 -0
  32. data/synth/README.md +1 -3
  33. metadata +12 -1
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative 'base_adapter'
7
+
8
+ module LanguageOperator
9
+ module Learning
10
+ module Adapters
11
+ # Jaeger backend adapter for trace queries
12
+ #
13
+ # Queries Jaeger's trace storage via gRPC QueryService API (port 16685).
14
+ # Falls back to HTTP API (port 16686) if gRPC is unavailable, though
15
+ # HTTP API is undocumented and not recommended for production use.
16
+ #
17
+ # Note: This implementation uses HTTP fallback initially. Full gRPC support
18
+ # requires the 'grpc' gem and generated protobuf stubs.
19
+ #
20
+ # @example Basic usage
21
+ # adapter = JaegerAdapter.new('http://jaeger-query:16686')
22
+ #
23
+ # spans = adapter.query_spans(
24
+ # filter: { task_name: 'fetch_data' },
25
+ # time_range: (Time.now - 3600)..Time.now,
26
+ # limit: 100
27
+ # )
28
+ class JaegerAdapter < BaseAdapter
29
+ # Jaeger HTTP API search endpoint
30
+ SEARCH_PATH = '/api/traces'
31
+
32
+ # Jaeger gRPC port (for future gRPC implementation)
33
+ GRPC_PORT = 16_685
34
+
35
+ # Check if Jaeger is available at endpoint
36
+ #
37
+ # @param endpoint [String] Jaeger endpoint URL
38
+ # @param _api_key [String, nil] API key (unused, Jaeger typically doesn't require auth)
39
+ # @return [Boolean] True if Jaeger API is reachable
40
+ def self.available?(endpoint, _api_key = nil)
41
+ # Try HTTP query endpoint first
42
+ uri = URI.join(endpoint, SEARCH_PATH)
43
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 2, read_timeout: 2) do |http|
44
+ request = Net::HTTP::Get.new("#{uri.path}?service=test&limit=1")
45
+ http.request(request)
46
+ end
47
+
48
+ response.is_a?(Net::HTTPSuccess)
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Query spans from Jaeger
54
+ #
55
+ # Uses HTTP API for search. Note: Jaeger searches by trace attributes (tags),
56
+ # returning traces that contain at least one matching span.
57
+ #
58
+ # @param filter [Hash] Filter criteria
59
+ # @option filter [String] :task_name Task name to filter by
60
+ # @param time_range [Range<Time>] Time range for query
61
+ # @param limit [Integer] Maximum traces to return
62
+ # @return [Array<Hash>] Normalized span data
63
+ def query_spans(filter:, time_range:, limit:)
64
+ times = parse_time_range(time_range)
65
+ traces = search_traces(filter, times, limit)
66
+ extract_spans_from_traces(traces)
67
+ end
68
+
69
+ private
70
+
71
+ # Search traces via Jaeger HTTP API
72
+ #
73
+ # @param filter [Hash] Filter criteria
74
+ # @param times [Hash] Start and end times
75
+ # @param limit [Integer] Result limit
76
+ # @return [Array<Hash>] Trace data
77
+ def search_traces(filter, times, limit)
78
+ uri = build_search_uri(filter, times, limit)
79
+
80
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 30) do |http|
81
+ request = Net::HTTP::Get.new(uri.request_uri)
82
+ request['Accept'] = 'application/json'
83
+
84
+ response = http.request(request)
85
+
86
+ raise "Jaeger query failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
87
+
88
+ result = JSON.parse(response.body, symbolize_names: true)
89
+ result[:data] || []
90
+ end
91
+ end
92
+
93
+ # Build Jaeger search URI with query parameters
94
+ #
95
+ # @param filter [Hash] Filter criteria
96
+ # @param times [Hash] Start and end times
97
+ # @param limit [Integer] Result limit
98
+ # @return [URI] Complete URI with query params
99
+ def build_search_uri(filter, times, limit)
100
+ params = {
101
+ limit: limit,
102
+ start: (times[:start].to_f * 1_000_000).to_i, # Microseconds
103
+ end: (times[:end].to_f * 1_000_000).to_i
104
+ }
105
+
106
+ # Jaeger requires a service name for search
107
+ # We use a wildcard or extract from task name
108
+ params[:service] = extract_service_name(filter)
109
+
110
+ # Add tag filters
111
+ params[:tags] = { 'task.name' => filter[:task_name] }.to_json if filter[:task_name]
112
+
113
+ uri = URI.join(@endpoint, SEARCH_PATH)
114
+ uri.query = URI.encode_www_form(params)
115
+ uri
116
+ end
117
+
118
+ # Extract service name from filter or use wildcard
119
+ #
120
+ # @param filter [Hash] Filter criteria
121
+ # @return [String] Service name
122
+ def extract_service_name(filter)
123
+ # Jaeger requires service name, but we don't always know it
124
+ # Use task name prefix or wildcard
125
+ if filter[:task_name]
126
+ # Assume service name from task (e.g., "user_service.fetch_user")
127
+ parts = filter[:task_name].split('.')
128
+ parts.size > 1 ? parts[0] : 'agent'
129
+ else
130
+ 'agent' # Default service name
131
+ end
132
+ end
133
+
134
+ # Extract all spans from traces
135
+ #
136
+ # @param traces [Array<Hash>] Jaeger trace data
137
+ # @return [Array<Hash>] Normalized spans
138
+ def extract_spans_from_traces(traces)
139
+ spans = []
140
+
141
+ traces.each do |trace|
142
+ trace_id = trace[:traceID]
143
+ process_map = build_process_map(trace[:processes])
144
+
145
+ (trace[:spans] || []).each do |span_data|
146
+ spans << normalize_span(span_data, trace_id, process_map)
147
+ end
148
+ end
149
+
150
+ spans
151
+ end
152
+
153
+ # Build process map for resource attributes
154
+ #
155
+ # @param processes [Hash] Process definitions
156
+ # @return [Hash] Process ID to name mapping
157
+ def build_process_map(processes)
158
+ return {} unless processes.is_a?(Hash)
159
+
160
+ processes.transform_values do |process|
161
+ process[:serviceName] || 'unknown'
162
+ end
163
+ end
164
+
165
+ # Normalize Jaeger span to common format
166
+ #
167
+ # @param span_data [Hash] Raw Jaeger span
168
+ # @param trace_id [String] Trace ID
169
+ # @param process_map [Hash] Process mapping
170
+ # @return [Hash] Normalized span
171
+ def normalize_span(span_data, trace_id, process_map)
172
+ process_id = span_data[:processID] || 'p1'
173
+ service_name = process_map[process_id] || 'unknown'
174
+
175
+ {
176
+ span_id: span_data[:spanID],
177
+ trace_id: trace_id,
178
+ name: span_data[:operationName] || service_name,
179
+ timestamp: parse_timestamp(span_data[:startTime]),
180
+ duration_ms: (span_data[:duration] || 0) / 1000.0, # Microseconds to milliseconds
181
+ attributes: parse_tags(span_data[:tags])
182
+ }
183
+ end
184
+
185
+ # Parse Jaeger timestamp (microseconds) to Time
186
+ #
187
+ # @param timestamp [Integer] Timestamp in microseconds
188
+ # @return [Time] Parsed time
189
+ def parse_timestamp(timestamp)
190
+ return Time.now unless timestamp
191
+
192
+ Time.at(timestamp / 1_000_000.0)
193
+ end
194
+
195
+ # Parse Jaeger tags into flat attributes hash
196
+ #
197
+ # @param tags [Array<Hash>] Tag array
198
+ # @return [Hash] Flat attributes
199
+ def parse_tags(tags)
200
+ return {} unless tags.is_a?(Array)
201
+
202
+ tags.each_with_object({}) do |tag, attrs|
203
+ key = tag[:key].to_s
204
+ value = tag[:value]
205
+
206
+ # Jaeger tags have type-specific value fields
207
+ # Extract the actual value
208
+ attrs[key] = if value.is_a?(Hash)
209
+ value[:stringValue] || value[:intValue] || value[:floatValue] || value[:boolValue]
210
+ else
211
+ value
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'time'
7
+ require_relative 'base_adapter'
8
+
9
+ module LanguageOperator
10
+ module Learning
11
+ module Adapters
12
+ # SigNoz backend adapter for trace queries
13
+ #
14
+ # Queries SigNoz's ClickHouse-backed trace storage via the /api/v5/query_range
15
+ # HTTP endpoint. Supports filtering by span attributes with AND/OR logic.
16
+ #
17
+ # @example Basic usage
18
+ # adapter = SignozAdapter.new(
19
+ # 'https://example.signoz.io',
20
+ # 'your-api-key'
21
+ # )
22
+ #
23
+ # spans = adapter.query_spans(
24
+ # filter: { task_name: 'fetch_data' },
25
+ # time_range: (Time.now - 3600)..Time.now,
26
+ # limit: 100
27
+ # )
28
+ # rubocop:disable Metrics/ClassLength
29
+ class SignozAdapter < BaseAdapter
30
+ # SigNoz query endpoint path
31
+ QUERY_PATH = '/api/v5/query_range'
32
+
33
+ # Check if SigNoz is available at endpoint
34
+ #
35
+ # @param endpoint [String] SigNoz endpoint URL
36
+ # @param api_key [String, nil] API key for authentication (optional)
37
+ # @return [Boolean] True if SigNoz API is reachable
38
+ def self.available?(endpoint, api_key = nil)
39
+ uri = URI.join(endpoint, QUERY_PATH)
40
+
41
+ # Test with minimal POST request since HEAD returns HTML web UI
42
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 30) do |http|
43
+ request = Net::HTTP::Post.new(uri.path)
44
+ request['Content-Type'] = 'application/json'
45
+ request['SIGNOZ-API-KEY'] = api_key if api_key
46
+ request.body = '{}'
47
+ http.request(request)
48
+ end
49
+
50
+ # Accept both success (200) and error responses (400) - both indicate API is working
51
+ # Reject only network/auth failures
52
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPClientError)
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ # Query spans from SigNoz
58
+ #
59
+ # @param filter [Hash] Filter criteria
60
+ # @option filter [String] :task_name Task name to filter by
61
+ # @param time_range [Range<Time>] Time range for query
62
+ # @param limit [Integer] Maximum spans to return
63
+ # @return [Array<Hash>] Normalized span data
64
+ def query_spans(filter:, time_range:, limit:)
65
+ times = parse_time_range(time_range)
66
+
67
+ # First query: get task spans to find trace IDs
68
+ task_request = build_query_request(filter, times, limit)
69
+ task_response = execute_query(task_request)
70
+ task_spans = parse_response(task_response)
71
+
72
+ return task_spans if task_spans.empty?
73
+
74
+ # Collect unique trace IDs
75
+ trace_ids = task_spans.map { |s| s[:trace_id] }.compact.uniq
76
+
77
+ return task_spans if trace_ids.empty?
78
+
79
+ # Second query: get tool spans within those traces
80
+ tool_spans = query_tool_spans_by_traces(trace_ids, times, limit * 10)
81
+
82
+ # Merge task spans and tool spans
83
+ task_spans + tool_spans
84
+ end
85
+
86
+ private
87
+
88
+ # Query tool execution spans by trace IDs
89
+ #
90
+ # @param trace_ids [Array<String>] Trace IDs to query
91
+ # @param times [Hash] Time range
92
+ # @param limit [Integer] Max results
93
+ # @return [Array<Hash>] Tool spans
94
+ def query_tool_spans_by_traces(trace_ids, times, limit)
95
+ return [] if trace_ids.empty?
96
+
97
+ # Build filter for tool spans in these traces
98
+ trace_filter = trace_ids.map { |id| "traceID = '#{id}'" }.join(' OR ')
99
+ filter_expr = "(#{trace_filter}) AND gen_ai.operation.name = 'execute_tool'"
100
+
101
+ request_body = build_tool_query_request(filter_expr, times, limit)
102
+ response = execute_query(request_body)
103
+ parse_response(response)
104
+ rescue StandardError => e
105
+ @logger.warn("Failed to query tool spans: #{e.message}")
106
+ []
107
+ end
108
+
109
+ # Build query request for tool spans with explicit filter
110
+ #
111
+ # @param filter_expr [String] Filter expression
112
+ # @param times [Hash] Time range
113
+ # @param limit [Integer] Result limit
114
+ # @return [Hash] Request body
115
+ def build_tool_query_request(filter_expr, times, limit)
116
+ {
117
+ start: (times[:start].to_f * 1000).to_i,
118
+ end: (times[:end].to_f * 1000).to_i,
119
+ requestType: 'raw',
120
+ variables: {},
121
+ compositeQuery: {
122
+ queries: [
123
+ {
124
+ type: 'builder_query',
125
+ spec: {
126
+ name: 'A',
127
+ signal: 'traces',
128
+ filter: { expression: filter_expr },
129
+ selectFields: [
130
+ { name: 'spanID' },
131
+ { name: 'traceID' },
132
+ { name: 'timestamp' },
133
+ { name: 'durationNano' },
134
+ { name: 'name' },
135
+ { name: 'serviceName' },
136
+ { name: 'gen_ai.operation.name' },
137
+ { name: 'gen_ai.tool.name' },
138
+ { name: 'gen_ai.tool.call.arguments.size' },
139
+ { name: 'gen_ai.tool.call.result.size' }
140
+ ],
141
+ order: [{ key: { name: 'timestamp' }, direction: 'asc' }],
142
+ limit: limit,
143
+ offset: 0,
144
+ disabled: false
145
+ }
146
+ }
147
+ ]
148
+ }
149
+ }
150
+ end
151
+
152
+ # Build SigNoz v5 query request body
153
+ #
154
+ # @param filter [Hash] Filter criteria
155
+ # @param times [Hash] Start and end times
156
+ # @param limit [Integer] Result limit
157
+ # @return [Hash] Request body
158
+ # rubocop:disable Metrics/MethodLength
159
+ def build_query_request(filter, times, limit)
160
+ {
161
+ start: (times[:start].to_f * 1000).to_i, # Unix milliseconds
162
+ end: (times[:end].to_f * 1000).to_i,
163
+ requestType: 'raw',
164
+ variables: {},
165
+ compositeQuery: {
166
+ queries: [
167
+ {
168
+ type: 'builder_query',
169
+ spec: {
170
+ name: 'A',
171
+ signal: 'traces',
172
+ filter: build_filter_expression(filter),
173
+ selectFields: [
174
+ { name: 'spanID' },
175
+ { name: 'traceID' },
176
+ { name: 'timestamp' },
177
+ { name: 'durationNano' },
178
+ { name: 'name' },
179
+ { name: 'serviceName' },
180
+ { name: 'task.name' },
181
+ { name: 'task.input.keys' },
182
+ { name: 'task.input.count' },
183
+ { name: 'task.output.keys' },
184
+ { name: 'task.output.count' },
185
+ { name: 'gen_ai.operation.name' },
186
+ { name: 'gen_ai.tool.name' },
187
+ { name: 'gen_ai.tool.call.arguments.size' },
188
+ { name: 'gen_ai.tool.call.result.size' }
189
+ ],
190
+ order: [
191
+ {
192
+ key: { name: 'timestamp' },
193
+ direction: 'desc'
194
+ }
195
+ ],
196
+ limit: limit,
197
+ offset: 0,
198
+ disabled: false
199
+ }
200
+ }
201
+ ]
202
+ }
203
+ }
204
+ end
205
+ # rubocop:enable Metrics/MethodLength
206
+
207
+ # Build filter expression for SigNoz v5 query
208
+ #
209
+ # SigNoz v5 filter syntax: attribute_name = 'value' (attribute name unquoted)
210
+ #
211
+ # @param filter [Hash] Filter criteria
212
+ # @return [Hash] Filter expression structure
213
+ def build_filter_expression(filter)
214
+ expressions = []
215
+
216
+ # Filter by task name (attribute name should NOT be quoted)
217
+ expressions << "task.name = '#{filter[:task_name]}'" if filter[:task_name]
218
+
219
+ # Additional attribute filters
220
+ if filter[:attributes].is_a?(Hash)
221
+ filter[:attributes].each do |key, value|
222
+ expressions << "#{key} = '#{value}'"
223
+ end
224
+ end
225
+
226
+ # Return filter expression (v5 format)
227
+ if expressions.empty?
228
+ { expression: '' }
229
+ else
230
+ { expression: expressions.join(' AND ') }
231
+ end
232
+ end
233
+
234
+ # Build filter items for SigNoz query (legacy, kept for reference)
235
+ #
236
+ # @param filter [Hash] Filter criteria
237
+ # @return [Hash] Filters structure
238
+ def build_filters(filter)
239
+ items = []
240
+
241
+ # Filter by task name (tag attribute)
242
+ if filter[:task_name]
243
+ items << {
244
+ key: {
245
+ key: 'task.name',
246
+ dataType: 'string',
247
+ type: 'tag'
248
+ },
249
+ op: '=',
250
+ value: filter[:task_name]
251
+ }
252
+ end
253
+
254
+ # Additional attribute filters
255
+ if filter[:attributes].is_a?(Hash)
256
+ filter[:attributes].each do |key, value|
257
+ items << {
258
+ key: {
259
+ key: key.to_s,
260
+ dataType: infer_data_type(value),
261
+ type: 'tag'
262
+ },
263
+ op: '=',
264
+ value: value
265
+ }
266
+ end
267
+ end
268
+
269
+ {
270
+ items: items,
271
+ op: 'AND'
272
+ }
273
+ end
274
+
275
+ # Infer SigNoz data type from value
276
+ #
277
+ # @param value [Object] Value to inspect
278
+ # @return [String] Data type ('string', 'int64', 'float64', 'bool')
279
+ def infer_data_type(value)
280
+ case value
281
+ when Integer then 'int64'
282
+ when Float then 'float64'
283
+ when TrueClass, FalseClass then 'bool'
284
+ else 'string'
285
+ end
286
+ end
287
+
288
+ # Execute HTTP query to SigNoz
289
+ #
290
+ # @param request_body [Hash] Request body
291
+ # @return [Hash] Parsed response
292
+ def execute_query(request_body)
293
+ uri = URI.join(@endpoint, QUERY_PATH)
294
+
295
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 60) do |http|
296
+ request = Net::HTTP::Post.new(uri.path)
297
+ request['Content-Type'] = 'application/json'
298
+ request['SIGNOZ-API-KEY'] = @api_key if @api_key
299
+ request.body = JSON.generate(request_body)
300
+
301
+ @logger&.debug("SigNoz Query: #{JSON.pretty_generate(request_body)}")
302
+
303
+ response = http.request(request)
304
+
305
+ unless response.is_a?(Net::HTTPSuccess)
306
+ @logger&.error("SigNoz Error Response: #{response.body}")
307
+ raise "SigNoz query failed: #{response.code} #{response.message}"
308
+ end
309
+
310
+ JSON.parse(response.body, symbolize_names: true)
311
+ end
312
+ end
313
+
314
+ # Parse SigNoz v5 response into normalized spans
315
+ #
316
+ # @param response [Hash] SigNoz API response
317
+ # @return [Array<Hash>] Normalized span data
318
+ def parse_response(response)
319
+ # SigNoz v5 response structure:
320
+ # {
321
+ # data: {
322
+ # data: {
323
+ # results: [
324
+ # {
325
+ # queryName: 'A',
326
+ # rows: [
327
+ # { data: { spanID, traceID, ... }, timestamp: '...' }
328
+ # ]
329
+ # }
330
+ # ]
331
+ # }
332
+ # }
333
+ # }
334
+
335
+ results = response.dig(:data, :data, :results) || []
336
+ spans = []
337
+
338
+ results.each do |result|
339
+ rows = result[:rows] || []
340
+ rows.each do |row|
341
+ span_data = row[:data] || {}
342
+ spans << normalize_span(span_data)
343
+ end
344
+ end
345
+
346
+ spans
347
+ end
348
+
349
+ # Normalize SigNoz span to common format
350
+ #
351
+ # @param span_data [Hash] Raw SigNoz span
352
+ # @return [Hash] Normalized span
353
+ def normalize_span(span_data)
354
+ {
355
+ span_id: span_data[:spanID] || span_data[:span_id] || span_data[:span_id],
356
+ trace_id: span_data[:traceID] || span_data[:trace_id] || span_data[:trace_id],
357
+ name: span_data[:name] || span_data[:serviceName],
358
+ timestamp: parse_timestamp(span_data[:timestamp]),
359
+ duration_ms: (span_data[:durationNano] || span_data[:duration_nano] || 0) / 1_000_000.0,
360
+ attributes: extract_attributes_from_span_data(span_data)
361
+ }
362
+ end
363
+
364
+ # Parse SigNoz timestamp (v5 uses ISO 8601 strings, legacy uses nanoseconds)
365
+ #
366
+ # @param timestamp [String, Integer] ISO 8601 timestamp string or nanoseconds
367
+ # @return [Time] Parsed time
368
+ def parse_timestamp(timestamp)
369
+ return Time.now unless timestamp
370
+
371
+ # v5 returns ISO 8601 strings
372
+ if timestamp.is_a?(String)
373
+ Time.parse(timestamp)
374
+ else
375
+ # Legacy format: nanoseconds
376
+ Time.at(timestamp / 1_000_000_000.0)
377
+ end
378
+ end
379
+
380
+ # Extract attributes from flat span data structure
381
+ #
382
+ # SigNoz v5 returns selected fields as flat keys in the span data object.
383
+ # We extract the attribute fields we requested in selectFields.
384
+ #
385
+ # @param span_data [Hash] Raw span data from SigNoz v5
386
+ # @return [Hash] Extracted attributes
387
+ def extract_attributes_from_span_data(span_data)
388
+ attrs = extract_known_attributes(span_data)
389
+ extract_tool_name_fallback(attrs, span_data)
390
+ attrs
391
+ end
392
+
393
+ def extract_known_attributes(span_data)
394
+ keys = %w[task.name task.input.keys task.input.count task.output.keys task.output.count
395
+ gen_ai.operation.name gen_ai.tool.name gen_ai.tool.call.arguments.size
396
+ gen_ai.tool.call.result.size]
397
+ keys.each_with_object({}) do |key, attrs|
398
+ val = span_data[key.to_sym]
399
+ attrs[key] = val if val
400
+ end
401
+ end
402
+
403
+ def extract_tool_name_fallback(attrs, span_data)
404
+ return unless attrs['gen_ai.tool.name'].nil? && span_data[:name]&.start_with?('execute_tool.')
405
+
406
+ tool_name = span_data[:name].sub('execute_tool.', '')
407
+ attrs['gen_ai.tool.name'] = tool_name unless tool_name.empty?
408
+ end
409
+
410
+ # Parse SigNoz tag maps into flat attributes hash (legacy)
411
+ #
412
+ # @param string_tags [Hash] String tag map
413
+ # @param number_tags [Hash] Number tag map
414
+ # @return [Hash] Flat attributes
415
+ def parse_attributes(string_tags, number_tags)
416
+ attrs = {}
417
+
418
+ (string_tags || {}).each do |key, value|
419
+ attrs[key.to_s] = value
420
+ end
421
+
422
+ (number_tags || {}).each do |key, value|
423
+ attrs[key.to_s] = value
424
+ end
425
+
426
+ attrs
427
+ end
428
+ end
429
+ # rubocop:enable Metrics/ClassLength
430
+ end
431
+ end
432
+ end