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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/components/agent/Gemfile +1 -1
- data/lib/language_operator/agent/base.rb +22 -0
- data/lib/language_operator/agent/task_executor.rb +80 -23
- data/lib/language_operator/agent/telemetry.rb +22 -11
- data/lib/language_operator/agent.rb +3 -0
- data/lib/language_operator/cli/base_command.rb +7 -1
- data/lib/language_operator/cli/commands/agent.rb +575 -0
- data/lib/language_operator/cli/formatters/optimization_formatter.rb +226 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +1 -1
- data/lib/language_operator/client/base.rb +74 -2
- data/lib/language_operator/client/mcp_connector.rb +4 -6
- data/lib/language_operator/dsl/task_definition.rb +7 -6
- data/lib/language_operator/learning/adapters/base_adapter.rb +149 -0
- data/lib/language_operator/learning/adapters/jaeger_adapter.rb +221 -0
- data/lib/language_operator/learning/adapters/signoz_adapter.rb +435 -0
- data/lib/language_operator/learning/adapters/tempo_adapter.rb +239 -0
- data/lib/language_operator/learning/optimizer.rb +319 -0
- data/lib/language_operator/learning/pattern_detector.rb +260 -0
- data/lib/language_operator/learning/task_synthesizer.rb +288 -0
- data/lib/language_operator/learning/trace_analyzer.rb +285 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/templates/task_synthesis.tmpl +98 -0
- data/lib/language_operator/ux/concerns/provider_helpers.rb +2 -2
- data/lib/language_operator/version.rb +1 -1
- data/synth/003/Makefile +10 -3
- data/synth/003/output.log +68 -0
- data/synth/README.md +1 -3
- 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
|