language-operator 0.1.58 → 0.1.61

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/components/agent/Gemfile +1 -1
  4. data/lib/language_operator/agent/base.rb +22 -0
  5. data/lib/language_operator/agent/task_executor.rb +80 -23
  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 +575 -0
  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 +74 -2
  13. data/lib/language_operator/client/mcp_connector.rb +4 -6
  14. data/lib/language_operator/dsl/task_definition.rb +7 -6
  15. data/lib/language_operator/learning/adapters/base_adapter.rb +149 -0
  16. data/lib/language_operator/learning/adapters/jaeger_adapter.rb +221 -0
  17. data/lib/language_operator/learning/adapters/signoz_adapter.rb +435 -0
  18. data/lib/language_operator/learning/adapters/tempo_adapter.rb +239 -0
  19. data/lib/language_operator/learning/optimizer.rb +319 -0
  20. data/lib/language_operator/learning/pattern_detector.rb +260 -0
  21. data/lib/language_operator/learning/task_synthesizer.rb +288 -0
  22. data/lib/language_operator/learning/trace_analyzer.rb +285 -0
  23. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  24. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  25. data/lib/language_operator/templates/task_synthesis.tmpl +98 -0
  26. data/lib/language_operator/ux/concerns/provider_helpers.rb +2 -2
  27. data/lib/language_operator/version.rb +1 -1
  28. data/synth/003/Makefile +10 -3
  29. data/synth/003/output.log +68 -0
  30. data/synth/README.md +1 -3
  31. metadata +12 -1
@@ -0,0 +1,221 @@
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
+ tags = {}
112
+ tags['task.name'] = filter[:task_name] if filter[:task_name]
113
+ tags['agent.name'] = filter[:agent_name] if filter[:agent_name]
114
+ params[:tags] = tags.to_json unless tags.empty?
115
+
116
+ uri = URI.join(@endpoint, SEARCH_PATH)
117
+ uri.query = URI.encode_www_form(params)
118
+ uri
119
+ end
120
+
121
+ # Extract service name from filter or use wildcard
122
+ #
123
+ # @param filter [Hash] Filter criteria
124
+ # @return [String] Service name
125
+ def extract_service_name(filter)
126
+ # Jaeger requires service name, but we don't always know it
127
+ # Use task name prefix or wildcard
128
+ if filter[:task_name]
129
+ # Assume service name from task (e.g., "user_service.fetch_user")
130
+ parts = filter[:task_name].split('.')
131
+ parts.size > 1 ? parts[0] : 'agent'
132
+ else
133
+ 'agent' # Default service name
134
+ end
135
+ end
136
+
137
+ # Extract all spans from traces
138
+ #
139
+ # @param traces [Array<Hash>] Jaeger trace data
140
+ # @return [Array<Hash>] Normalized spans
141
+ def extract_spans_from_traces(traces)
142
+ spans = []
143
+
144
+ traces.each do |trace|
145
+ trace_id = trace[:traceID]
146
+ process_map = build_process_map(trace[:processes])
147
+
148
+ (trace[:spans] || []).each do |span_data|
149
+ spans << normalize_span(span_data, trace_id, process_map)
150
+ end
151
+ end
152
+
153
+ spans
154
+ end
155
+
156
+ # Build process map for resource attributes
157
+ #
158
+ # @param processes [Hash] Process definitions
159
+ # @return [Hash] Process ID to name mapping
160
+ def build_process_map(processes)
161
+ return {} unless processes.is_a?(Hash)
162
+
163
+ processes.transform_values do |process|
164
+ process[:serviceName] || 'unknown'
165
+ end
166
+ end
167
+
168
+ # Normalize Jaeger span to common format
169
+ #
170
+ # @param span_data [Hash] Raw Jaeger span
171
+ # @param trace_id [String] Trace ID
172
+ # @param process_map [Hash] Process mapping
173
+ # @return [Hash] Normalized span
174
+ def normalize_span(span_data, trace_id, process_map)
175
+ process_id = span_data[:processID] || 'p1'
176
+ service_name = process_map[process_id] || 'unknown'
177
+
178
+ {
179
+ span_id: span_data[:spanID],
180
+ trace_id: trace_id,
181
+ name: span_data[:operationName] || service_name,
182
+ timestamp: parse_timestamp(span_data[:startTime]),
183
+ duration_ms: (span_data[:duration] || 0) / 1000.0, # Microseconds to milliseconds
184
+ attributes: parse_tags(span_data[:tags])
185
+ }
186
+ end
187
+
188
+ # Parse Jaeger timestamp (microseconds) to Time
189
+ #
190
+ # @param timestamp [Integer] Timestamp in microseconds
191
+ # @return [Time] Parsed time
192
+ def parse_timestamp(timestamp)
193
+ return Time.now unless timestamp
194
+
195
+ Time.at(timestamp / 1_000_000.0)
196
+ end
197
+
198
+ # Parse Jaeger tags into flat attributes hash
199
+ #
200
+ # @param tags [Array<Hash>] Tag array
201
+ # @return [Hash] Flat attributes
202
+ def parse_tags(tags)
203
+ return {} unless tags.is_a?(Array)
204
+
205
+ tags.each_with_object({}) do |tag, attrs|
206
+ key = tag[:key].to_s
207
+ value = tag[:value]
208
+
209
+ # Jaeger tags have type-specific value fields
210
+ # Extract the actual value
211
+ attrs[key] = if value.is_a?(Hash)
212
+ value[:stringValue] || value[:intValue] || value[:floatValue] || value[:boolValue]
213
+ else
214
+ value
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,435 @@
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
+ # Filter by agent name
220
+ expressions << "agent.name = '#{filter[:agent_name]}'" if filter[:agent_name]
221
+
222
+ # Additional attribute filters
223
+ if filter[:attributes].is_a?(Hash)
224
+ filter[:attributes].each do |key, value|
225
+ expressions << "#{key} = '#{value}'"
226
+ end
227
+ end
228
+
229
+ # Return filter expression (v5 format)
230
+ if expressions.empty?
231
+ { expression: '' }
232
+ else
233
+ { expression: expressions.join(' AND ') }
234
+ end
235
+ end
236
+
237
+ # Build filter items for SigNoz query (legacy, kept for reference)
238
+ #
239
+ # @param filter [Hash] Filter criteria
240
+ # @return [Hash] Filters structure
241
+ def build_filters(filter)
242
+ items = []
243
+
244
+ # Filter by task name (tag attribute)
245
+ if filter[:task_name]
246
+ items << {
247
+ key: {
248
+ key: 'task.name',
249
+ dataType: 'string',
250
+ type: 'tag'
251
+ },
252
+ op: '=',
253
+ value: filter[:task_name]
254
+ }
255
+ end
256
+
257
+ # Additional attribute filters
258
+ if filter[:attributes].is_a?(Hash)
259
+ filter[:attributes].each do |key, value|
260
+ items << {
261
+ key: {
262
+ key: key.to_s,
263
+ dataType: infer_data_type(value),
264
+ type: 'tag'
265
+ },
266
+ op: '=',
267
+ value: value
268
+ }
269
+ end
270
+ end
271
+
272
+ {
273
+ items: items,
274
+ op: 'AND'
275
+ }
276
+ end
277
+
278
+ # Infer SigNoz data type from value
279
+ #
280
+ # @param value [Object] Value to inspect
281
+ # @return [String] Data type ('string', 'int64', 'float64', 'bool')
282
+ def infer_data_type(value)
283
+ case value
284
+ when Integer then 'int64'
285
+ when Float then 'float64'
286
+ when TrueClass, FalseClass then 'bool'
287
+ else 'string'
288
+ end
289
+ end
290
+
291
+ # Execute HTTP query to SigNoz
292
+ #
293
+ # @param request_body [Hash] Request body
294
+ # @return [Hash] Parsed response
295
+ def execute_query(request_body)
296
+ uri = URI.join(@endpoint, QUERY_PATH)
297
+
298
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 60) do |http|
299
+ request = Net::HTTP::Post.new(uri.path)
300
+ request['Content-Type'] = 'application/json'
301
+ request['SIGNOZ-API-KEY'] = @api_key if @api_key
302
+ request.body = JSON.generate(request_body)
303
+
304
+ @logger&.debug("SigNoz Query: #{JSON.pretty_generate(request_body)}")
305
+
306
+ response = http.request(request)
307
+
308
+ unless response.is_a?(Net::HTTPSuccess)
309
+ @logger&.error("SigNoz Error Response: #{response.body}")
310
+ raise "SigNoz query failed: #{response.code} #{response.message}"
311
+ end
312
+
313
+ JSON.parse(response.body, symbolize_names: true)
314
+ end
315
+ end
316
+
317
+ # Parse SigNoz v5 response into normalized spans
318
+ #
319
+ # @param response [Hash] SigNoz API response
320
+ # @return [Array<Hash>] Normalized span data
321
+ def parse_response(response)
322
+ # SigNoz v5 response structure:
323
+ # {
324
+ # data: {
325
+ # data: {
326
+ # results: [
327
+ # {
328
+ # queryName: 'A',
329
+ # rows: [
330
+ # { data: { spanID, traceID, ... }, timestamp: '...' }
331
+ # ]
332
+ # }
333
+ # ]
334
+ # }
335
+ # }
336
+ # }
337
+
338
+ results = response.dig(:data, :data, :results) || []
339
+ spans = []
340
+
341
+ results.each do |result|
342
+ rows = result[:rows] || []
343
+ rows.each do |row|
344
+ span_data = row[:data] || {}
345
+ spans << normalize_span(span_data)
346
+ end
347
+ end
348
+
349
+ spans
350
+ end
351
+
352
+ # Normalize SigNoz span to common format
353
+ #
354
+ # @param span_data [Hash] Raw SigNoz span
355
+ # @return [Hash] Normalized span
356
+ def normalize_span(span_data)
357
+ {
358
+ span_id: span_data[:spanID] || span_data[:span_id] || span_data[:span_id],
359
+ trace_id: span_data[:traceID] || span_data[:trace_id] || span_data[:trace_id],
360
+ name: span_data[:name] || span_data[:serviceName],
361
+ timestamp: parse_timestamp(span_data[:timestamp]),
362
+ duration_ms: (span_data[:durationNano] || span_data[:duration_nano] || 0) / 1_000_000.0,
363
+ attributes: extract_attributes_from_span_data(span_data)
364
+ }
365
+ end
366
+
367
+ # Parse SigNoz timestamp (v5 uses ISO 8601 strings, legacy uses nanoseconds)
368
+ #
369
+ # @param timestamp [String, Integer] ISO 8601 timestamp string or nanoseconds
370
+ # @return [Time] Parsed time
371
+ def parse_timestamp(timestamp)
372
+ return Time.now unless timestamp
373
+
374
+ # v5 returns ISO 8601 strings
375
+ if timestamp.is_a?(String)
376
+ Time.parse(timestamp)
377
+ else
378
+ # Legacy format: nanoseconds
379
+ Time.at(timestamp / 1_000_000_000.0)
380
+ end
381
+ end
382
+
383
+ # Extract attributes from flat span data structure
384
+ #
385
+ # SigNoz v5 returns selected fields as flat keys in the span data object.
386
+ # We extract the attribute fields we requested in selectFields.
387
+ #
388
+ # @param span_data [Hash] Raw span data from SigNoz v5
389
+ # @return [Hash] Extracted attributes
390
+ def extract_attributes_from_span_data(span_data)
391
+ attrs = extract_known_attributes(span_data)
392
+ extract_tool_name_fallback(attrs, span_data)
393
+ attrs
394
+ end
395
+
396
+ def extract_known_attributes(span_data)
397
+ keys = %w[task.name task.input.keys task.input.count task.output.keys task.output.count
398
+ gen_ai.operation.name gen_ai.tool.name gen_ai.tool.call.arguments.size
399
+ gen_ai.tool.call.result.size]
400
+ keys.each_with_object({}) do |key, attrs|
401
+ val = span_data[key.to_sym]
402
+ attrs[key] = val if val
403
+ end
404
+ end
405
+
406
+ def extract_tool_name_fallback(attrs, span_data)
407
+ return unless attrs['gen_ai.tool.name'].nil? && span_data[:name]&.start_with?('execute_tool.')
408
+
409
+ tool_name = span_data[:name].sub('execute_tool.', '')
410
+ attrs['gen_ai.tool.name'] = tool_name unless tool_name.empty?
411
+ end
412
+
413
+ # Parse SigNoz tag maps into flat attributes hash (legacy)
414
+ #
415
+ # @param string_tags [Hash] String tag map
416
+ # @param number_tags [Hash] Number tag map
417
+ # @return [Hash] Flat attributes
418
+ def parse_attributes(string_tags, number_tags)
419
+ attrs = {}
420
+
421
+ (string_tags || {}).each do |key, value|
422
+ attrs[key.to_s] = value
423
+ end
424
+
425
+ (number_tags || {}).each do |key, value|
426
+ attrs[key.to_s] = value
427
+ end
428
+
429
+ attrs
430
+ end
431
+ end
432
+ # rubocop:enable Metrics/ClassLength
433
+ end
434
+ end
435
+ end