agentbill-sdk 2.0.1 → 7.17.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 +4 -4
- data/.github/workflows/ci.yml +5 -4
- data/CHANGELOG.md +67 -13
- data/README.md +4 -3
- data/SECURITY.md +2 -2
- data/examples/ollama_basic.rb +81 -0
- data/examples/openai_custom_event.rb +50 -0
- data/examples/perplexity_basic.rb +66 -0
- data/lib/agentbill/agents.rb +226 -0
- data/lib/agentbill/customers.rb +164 -0
- data/lib/agentbill/distributed.rb +109 -0
- data/lib/agentbill/exceptions.rb +84 -0
- data/lib/agentbill/ollama_wrapper.rb +153 -0
- data/lib/agentbill/orders.rb +283 -0
- data/lib/agentbill/perplexity_wrapper.rb +101 -0
- data/lib/agentbill/pricing.rb +52 -0
- data/lib/agentbill/signal_types.rb +179 -0
- data/lib/agentbill/signals.rb +199 -0
- data/lib/agentbill/tracer.rb +69 -12
- data/lib/agentbill/tracing.rb +343 -0
- data/lib/agentbill/version.rb +1 -1
- data/lib/agentbill/wrappers.rb +384 -0
- data/lib/agentbill.rb +288 -52
- metadata +17 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'time'
|
|
8
|
+
|
|
9
|
+
require_relative 'distributed'
|
|
10
|
+
|
|
11
|
+
module AgentBill
|
|
12
|
+
# Signal module for tracking business events and linking revenue to AI traces.
|
|
13
|
+
module Signals
|
|
14
|
+
BASE_URL = 'https://api.agentbill.io'
|
|
15
|
+
VERSION = '7.16.1'
|
|
16
|
+
|
|
17
|
+
@config = {}
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :config
|
|
21
|
+
|
|
22
|
+
def set_config(options)
|
|
23
|
+
@config = options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_config
|
|
27
|
+
@config
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Emit a business signal/event and link it to AI traces.
|
|
34
|
+
#
|
|
35
|
+
# @param event_name [String] Name of the business event
|
|
36
|
+
# @param options [Hash] Signal options
|
|
37
|
+
# @option options [Float] :revenue Revenue amount
|
|
38
|
+
# @option options [Hash] :metadata Additional metadata
|
|
39
|
+
# @option options [String] :customer_id Customer ID
|
|
40
|
+
# @option options [String] :session_id Session ID
|
|
41
|
+
# @option options [String] :trace_id Trace ID to link to
|
|
42
|
+
# @option options [String] :span_id Span ID to link to
|
|
43
|
+
# @option options [String] :parent_span_id Parent span ID
|
|
44
|
+
# @option options [String] :currency Currency (default: USD)
|
|
45
|
+
# @option options [String] :event_type Event type
|
|
46
|
+
# @option options [Float] :event_value Event value
|
|
47
|
+
# @option options [String] :order_id Order ID to link to (v7.8.0)
|
|
48
|
+
# @option options [String] :order_external_id External order ID (v7.8.0)
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash] Result with success status
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# AgentBill.signal('purchase', revenue: 99.99, metadata: { product_id: 'prod-123' })
|
|
54
|
+
def signal(event_name, **options)
|
|
55
|
+
config = Signals.get_config
|
|
56
|
+
|
|
57
|
+
api_key = config[:api_key]
|
|
58
|
+
raise ArgumentError, 'AgentBill not initialized. Call AgentBill.init() first or use AgentBill.tracing context.' unless api_key
|
|
59
|
+
|
|
60
|
+
effective_customer_id = options[:customer_id] || config[:customer_id]
|
|
61
|
+
|
|
62
|
+
# Auto-detect trace context if not provided
|
|
63
|
+
ctx = Distributed.get_trace_context
|
|
64
|
+
trace_id = options[:trace_id]
|
|
65
|
+
parent_span_id = options[:parent_span_id]
|
|
66
|
+
|
|
67
|
+
if ctx
|
|
68
|
+
trace_id ||= ctx[:trace_id]
|
|
69
|
+
parent_span_id ||= ctx[:span_id] unless parent_span_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
generated_span_id = options[:span_id] || SecureRandom.hex(8)
|
|
73
|
+
generated_trace_id = trace_id || SecureRandom.hex(16)
|
|
74
|
+
now_ns = (Time.now.to_f * 1_000_000_000).to_i
|
|
75
|
+
|
|
76
|
+
# Build OTEL-compliant span attributes
|
|
77
|
+
attributes = [
|
|
78
|
+
{ key: 'agentbill.event_name', value: { stringValue: event_name } },
|
|
79
|
+
{ key: 'agentbill.is_business_event', value: { boolValue: true } }
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
attributes << { key: 'agentbill.customer_id', value: { stringValue: effective_customer_id } } if effective_customer_id
|
|
83
|
+
attributes << { key: 'agentbill.agent_id', value: { stringValue: config[:agent_id] } } if config[:agent_id]
|
|
84
|
+
|
|
85
|
+
if options[:revenue]
|
|
86
|
+
attributes << { key: 'agentbill.revenue', value: { doubleValue: options[:revenue] } }
|
|
87
|
+
attributes << { key: 'agentbill.currency', value: { stringValue: options[:currency] || 'USD' } }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attributes << { key: 'agentbill.event_value', value: { doubleValue: options[:event_value] } } if options[:event_value]
|
|
91
|
+
attributes << { key: 'agentbill.event_type', value: { stringValue: options[:event_type] } } if options[:event_type]
|
|
92
|
+
attributes << { key: 'agentbill.session_id', value: { stringValue: options[:session_id] } } if options[:session_id]
|
|
93
|
+
attributes << { key: 'agentbill.parent_span_id', value: { stringValue: parent_span_id } } if parent_span_id
|
|
94
|
+
# v7.8.0: Order linking
|
|
95
|
+
attributes << { key: 'agentbill.order_id', value: { stringValue: options[:order_id] } } if options[:order_id]
|
|
96
|
+
attributes << { key: 'agentbill.order_external_id', value: { stringValue: options[:order_external_id] } } if options[:order_external_id]
|
|
97
|
+
attributes << { key: 'agentbill.metadata', value: { stringValue: JSON.generate(options[:metadata]) } } if options[:metadata]
|
|
98
|
+
|
|
99
|
+
# Build OTEL payload
|
|
100
|
+
payload = {
|
|
101
|
+
resourceSpans: [{
|
|
102
|
+
resource: {
|
|
103
|
+
attributes: [
|
|
104
|
+
{ key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
|
|
105
|
+
{ key: 'agentbill.customer_id', value: { stringValue: effective_customer_id || '' } },
|
|
106
|
+
{ key: 'agentbill.agent_id', value: { stringValue: config[:agent_id] || '' } }
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
scopeSpans: [{
|
|
110
|
+
scope: { name: 'agentbill.signals', version: Signals::VERSION },
|
|
111
|
+
spans: [{
|
|
112
|
+
traceId: generated_trace_id,
|
|
113
|
+
spanId: generated_span_id,
|
|
114
|
+
parentSpanId: parent_span_id || '',
|
|
115
|
+
name: 'agentbill.trace.signal',
|
|
116
|
+
kind: 1,
|
|
117
|
+
startTimeUnixNano: now_ns.to_s,
|
|
118
|
+
endTimeUnixNano: now_ns.to_s,
|
|
119
|
+
attributes: attributes,
|
|
120
|
+
status: { code: 1 }
|
|
121
|
+
}]
|
|
122
|
+
}]
|
|
123
|
+
}]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
base_url = config[:base_url] || Signals::BASE_URL
|
|
127
|
+
debug = config[:debug] || false
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
url = URI("#{base_url}/functions/v1/otel-collector")
|
|
131
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
132
|
+
http.use_ssl = true
|
|
133
|
+
http.read_timeout = 10
|
|
134
|
+
|
|
135
|
+
request = Net::HTTP::Post.new(url)
|
|
136
|
+
request['x-api-key'] = api_key
|
|
137
|
+
request['Content-Type'] = 'application/json'
|
|
138
|
+
request.body = JSON.generate(payload)
|
|
139
|
+
|
|
140
|
+
response = http.request(request)
|
|
141
|
+
|
|
142
|
+
response_data = {}
|
|
143
|
+
body_success = true
|
|
144
|
+
begin
|
|
145
|
+
response_data = JSON.parse(response.body)
|
|
146
|
+
body_success = response_data['success'] != false
|
|
147
|
+
rescue StandardError
|
|
148
|
+
# If can't parse JSON, fall back to HTTP status check
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
is_success = response.code.to_i == 200 && body_success
|
|
152
|
+
|
|
153
|
+
if debug
|
|
154
|
+
if is_success
|
|
155
|
+
puts "[AgentBill] ✓ Signal '#{event_name}' tracked via OTEL"
|
|
156
|
+
puts "[AgentBill] Revenue: $#{'%.2f' % options[:revenue]} #{options[:currency] || 'USD'}" if options[:revenue]
|
|
157
|
+
puts "[AgentBill] Trace ID: #{generated_trace_id}"
|
|
158
|
+
else
|
|
159
|
+
puts "[AgentBill] ⚠️ Signal tracking failed: #{response_data['error'] || response.message}"
|
|
160
|
+
response_data['errors']&.each { |err| puts "[AgentBill] - #{err}" }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
success: is_success,
|
|
166
|
+
status_code: response.code.to_i,
|
|
167
|
+
trace_id: generated_trace_id,
|
|
168
|
+
span_id: generated_span_id,
|
|
169
|
+
error: is_success ? nil : response_data['error'],
|
|
170
|
+
errors: is_success ? nil : response_data['errors']
|
|
171
|
+
}
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
puts "[AgentBill] ⚠️ Signal tracking error: #{e.message}" if debug
|
|
174
|
+
{
|
|
175
|
+
success: false,
|
|
176
|
+
error: e.message,
|
|
177
|
+
trace_id: generated_trace_id
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Track a conversion event for revenue attribution.
|
|
183
|
+
#
|
|
184
|
+
# @param event_type [String] Type of conversion
|
|
185
|
+
# @param event_value [Float] Value of the conversion
|
|
186
|
+
# @param options [Hash] Additional options
|
|
187
|
+
#
|
|
188
|
+
# @return [Hash] Result with status
|
|
189
|
+
def track_conversion(event_type, event_value, **options)
|
|
190
|
+
signal(
|
|
191
|
+
"conversion_#{event_type}",
|
|
192
|
+
revenue: event_value,
|
|
193
|
+
event_type: event_type,
|
|
194
|
+
event_value: event_value,
|
|
195
|
+
**options
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/agentbill/tracer.rb
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
require 'net/http'
|
|
2
2
|
require 'json'
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative 'distributed'
|
|
4
5
|
|
|
5
6
|
module AgentBill
|
|
6
7
|
class Span
|
|
7
|
-
attr_accessor :name, :trace_id, :span_id, :attributes, :start_time, :end_time, :status
|
|
8
|
+
attr_accessor :name, :trace_id, :span_id, :parent_span_id, :attributes, :start_time, :end_time, :status
|
|
8
9
|
|
|
9
|
-
def initialize(name, trace_id, span_id, attributes)
|
|
10
|
+
def initialize(name, trace_id, span_id, parent_span_id, attributes)
|
|
10
11
|
@name = name
|
|
11
12
|
@trace_id = trace_id
|
|
12
13
|
@span_id = span_id
|
|
14
|
+
@parent_span_id = parent_span_id
|
|
13
15
|
@attributes = attributes
|
|
14
16
|
@start_time = (Time.now.to_f * 1_000_000_000).to_i
|
|
15
17
|
@end_time = nil
|
|
@@ -30,9 +32,21 @@ module AgentBill
|
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
class Tracer
|
|
35
|
+
# OpenTelemetry tracer for AgentBill
|
|
36
|
+
#
|
|
37
|
+
# IMPORTANT: This tracer integrates with Distributed module for trace context.
|
|
38
|
+
# - If a trace context exists (via Distributed.get_trace_context), it reuses the trace_id
|
|
39
|
+
# - Always generates a new span_id for each span
|
|
40
|
+
# - Updates the global trace context so get_trace_context returns correct values
|
|
41
|
+
#
|
|
42
|
+
# This ensures that:
|
|
43
|
+
# 1. get_trace_context always returns the trace_id that matches what's sent to the portal
|
|
44
|
+
# 2. Distributed tracing works correctly across services
|
|
45
|
+
# 3. Parent-child span relationships are properly maintained
|
|
46
|
+
|
|
33
47
|
def initialize(config)
|
|
34
48
|
@config = config
|
|
35
|
-
@base_url = config[:base_url] || 'https://
|
|
49
|
+
@base_url = config[:base_url] || 'https://api.agentbill.io'
|
|
36
50
|
@api_key = config[:api_key]
|
|
37
51
|
@customer_id = config[:customer_id]
|
|
38
52
|
@debug = config[:debug] || false
|
|
@@ -40,14 +54,37 @@ module AgentBill
|
|
|
40
54
|
end
|
|
41
55
|
|
|
42
56
|
def start_span(name, attributes)
|
|
43
|
-
|
|
57
|
+
# Check for existing trace context from Distributed module
|
|
58
|
+
ctx = Distributed.get_trace_context
|
|
59
|
+
parent_span_id = nil
|
|
60
|
+
|
|
61
|
+
if ctx
|
|
62
|
+
# Reuse existing trace_id for correlation
|
|
63
|
+
trace_id = ctx[:trace_id]
|
|
64
|
+
parent_span_id = ctx[:span_id]
|
|
65
|
+
puts "[AgentBill Tracer] Using existing trace context: trace_id=#{trace_id[0..7]}..." if @debug
|
|
66
|
+
else
|
|
67
|
+
# Generate new trace_id for new trace
|
|
68
|
+
trace_id = SecureRandom.hex(16)
|
|
69
|
+
puts "[AgentBill Tracer] Creating new trace: trace_id=#{trace_id[0..7]}..." if @debug
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Always generate new span_id for this span
|
|
44
73
|
span_id = SecureRandom.hex(8)
|
|
45
74
|
|
|
75
|
+
# Update global trace context so get_trace_context returns correct values
|
|
76
|
+
Distributed.set_trace_context(trace_id, span_id)
|
|
77
|
+
|
|
46
78
|
attributes['service.name'] = 'agentbill-ruby-sdk'
|
|
47
79
|
attributes['customer.id'] = @customer_id if @customer_id
|
|
48
80
|
|
|
49
|
-
span = Span.new(name, trace_id, span_id, attributes)
|
|
81
|
+
span = Span.new(name, trace_id, span_id, parent_span_id, attributes)
|
|
50
82
|
@spans << span
|
|
83
|
+
|
|
84
|
+
if @debug
|
|
85
|
+
puts "[AgentBill Tracer] Started span: #{name}, trace_id=#{trace_id[0..7]}..., span_id=#{span_id[0..7]}..."
|
|
86
|
+
end
|
|
87
|
+
|
|
51
88
|
span
|
|
52
89
|
end
|
|
53
90
|
|
|
@@ -61,7 +98,7 @@ module AgentBill
|
|
|
61
98
|
http.use_ssl = true
|
|
62
99
|
|
|
63
100
|
request = Net::HTTP::Post.new(uri.path)
|
|
64
|
-
request['
|
|
101
|
+
request['X-API-Key'] = @api_key
|
|
65
102
|
request['Content-Type'] = 'application/json'
|
|
66
103
|
request.body = payload.to_json
|
|
67
104
|
|
|
@@ -77,16 +114,31 @@ module AgentBill
|
|
|
77
114
|
private
|
|
78
115
|
|
|
79
116
|
def build_otlp_payload
|
|
117
|
+
# Build resource attributes - agent_id and customer_id must be sent here
|
|
118
|
+
# for the otel-collector to extract them correctly
|
|
119
|
+
resource_attrs = [
|
|
120
|
+
{ key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
|
|
121
|
+
{ key: 'service.version', value: { stringValue: '6.8.8' } }
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Add customer_id as resource attribute if configured
|
|
125
|
+
if @customer_id
|
|
126
|
+
resource_attrs << { key: 'customer.id', value: { stringValue: @customer_id } }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Add agent_id as resource attribute if configured
|
|
130
|
+
agent_id = @config[:agent_id]
|
|
131
|
+
if agent_id
|
|
132
|
+
resource_attrs << { key: 'agent.id', value: { stringValue: agent_id } }
|
|
133
|
+
end
|
|
134
|
+
|
|
80
135
|
{
|
|
81
136
|
resourceSpans: [{
|
|
82
137
|
resource: {
|
|
83
|
-
attributes:
|
|
84
|
-
{ key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
|
|
85
|
-
{ key: 'service.version', value: { stringValue: '1.0.0' } }
|
|
86
|
-
]
|
|
138
|
+
attributes: resource_attrs
|
|
87
139
|
},
|
|
88
140
|
scopeSpans: [{
|
|
89
|
-
scope: { name: 'agentbill', version: '
|
|
141
|
+
scope: { name: 'agentbill', version: '6.8.8' },
|
|
90
142
|
spans: @spans.map { |span| span_to_otlp(span) }
|
|
91
143
|
}]
|
|
92
144
|
}]
|
|
@@ -94,7 +146,7 @@ module AgentBill
|
|
|
94
146
|
end
|
|
95
147
|
|
|
96
148
|
def span_to_otlp(span)
|
|
97
|
-
{
|
|
149
|
+
otlp_span = {
|
|
98
150
|
traceId: span.trace_id,
|
|
99
151
|
spanId: span.span_id,
|
|
100
152
|
name: span.name,
|
|
@@ -104,6 +156,11 @@ module AgentBill
|
|
|
104
156
|
attributes: span.attributes.map { |k, v| { key: k, value: value_to_otlp(v) } },
|
|
105
157
|
status: span.status
|
|
106
158
|
}
|
|
159
|
+
|
|
160
|
+
# Add parent span ID if this is a child span
|
|
161
|
+
otlp_span[:parentSpanId] = span.parent_span_id if span.parent_span_id
|
|
162
|
+
|
|
163
|
+
otlp_span
|
|
107
164
|
end
|
|
108
165
|
|
|
109
166
|
def value_to_otlp(value)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
require_relative 'exceptions'
|
|
9
|
+
require_relative 'distributed'
|
|
10
|
+
require_relative 'signals'
|
|
11
|
+
|
|
12
|
+
module AgentBill
|
|
13
|
+
# Context manager for tracking AI operations with Cost Guard protection.
|
|
14
|
+
#
|
|
15
|
+
# Implements the flow from the sequence diagram:
|
|
16
|
+
# 1. Pre-validate with ai-cost-guard-router
|
|
17
|
+
# 2. If blocked, raise BudgetExceededError
|
|
18
|
+
# 3. If allowed, execute AI calls
|
|
19
|
+
# 4. After completion, send spans to otel-collector
|
|
20
|
+
class TracingContext
|
|
21
|
+
attr_reader :trace_id, :span_id, :parent_span_id, :request_id, :start_time, :validation_result
|
|
22
|
+
|
|
23
|
+
BASE_URL = 'https://api.agentbill.io'
|
|
24
|
+
VERSION = '7.16.1'
|
|
25
|
+
|
|
26
|
+
def initialize(options = {})
|
|
27
|
+
@api_key = options[:api_key] || ENV['AGENTBILL_API_KEY']
|
|
28
|
+
@customer_id = options[:customer_id]
|
|
29
|
+
@daily_budget = options[:daily_budget]
|
|
30
|
+
@monthly_budget = options[:monthly_budget]
|
|
31
|
+
@agent_id = options[:agent_id]
|
|
32
|
+
@base_url = options[:base_url] || BASE_URL
|
|
33
|
+
@debug = options[:debug] || false
|
|
34
|
+
@model = options[:model] || 'gpt-4'
|
|
35
|
+
@estimated_tokens = options[:estimated_tokens] || 1000
|
|
36
|
+
|
|
37
|
+
@trace_id = nil
|
|
38
|
+
@span_id = nil
|
|
39
|
+
@parent_span_id = nil
|
|
40
|
+
@request_id = nil
|
|
41
|
+
@start_time = nil
|
|
42
|
+
@validation_result = nil
|
|
43
|
+
@spans = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enter
|
|
47
|
+
generate_ids
|
|
48
|
+
@start_time = Time.now
|
|
49
|
+
|
|
50
|
+
# Set global config for signal() function
|
|
51
|
+
Signals.set_config(
|
|
52
|
+
api_key: @api_key,
|
|
53
|
+
customer_id: @customer_id,
|
|
54
|
+
agent_id: @agent_id,
|
|
55
|
+
base_url: @base_url,
|
|
56
|
+
debug: @debug
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# CRITICAL: Pre-validate with Cost Guard
|
|
60
|
+
@validation_result = validate_budget
|
|
61
|
+
|
|
62
|
+
# CRITICAL: Raise exception if not allowed
|
|
63
|
+
unless @validation_result['allowed']
|
|
64
|
+
reason = @validation_result['reason'] || 'Request blocked by Cost Guard'
|
|
65
|
+
reason_lower = reason.downcase
|
|
66
|
+
|
|
67
|
+
if reason_lower.include?('budget')
|
|
68
|
+
raise BudgetExceededError.new(reason, @validation_result)
|
|
69
|
+
elsif reason_lower.include?('rate')
|
|
70
|
+
raise RateLimitExceededError.new(reason, @validation_result)
|
|
71
|
+
else
|
|
72
|
+
raise PolicyViolationError.new(reason, @validation_result)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def exit(error = nil)
|
|
80
|
+
end_time = Time.now
|
|
81
|
+
|
|
82
|
+
# Add root span for the entire context
|
|
83
|
+
add_span(
|
|
84
|
+
'agentbill_tracing',
|
|
85
|
+
{
|
|
86
|
+
'customer_id' => @customer_id,
|
|
87
|
+
'agent_id' => @agent_id,
|
|
88
|
+
'request_id' => @request_id,
|
|
89
|
+
'error' => error&.message
|
|
90
|
+
},
|
|
91
|
+
(@start_time.to_f * 1_000_000_000).to_i,
|
|
92
|
+
(end_time.to_f * 1_000_000_000).to_i,
|
|
93
|
+
error ? 1 : 0
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Export spans to otel-collector
|
|
97
|
+
export_spans
|
|
98
|
+
|
|
99
|
+
# Clear trace context
|
|
100
|
+
Distributed.clear_trace_context
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_span(name, attributes, start_time_ns, end_time_ns, status = 0)
|
|
104
|
+
span = {
|
|
105
|
+
'trace_id' => @trace_id,
|
|
106
|
+
'span_id' => SecureRandom.hex(8),
|
|
107
|
+
'parent_span_id' => @span_id,
|
|
108
|
+
'name' => name,
|
|
109
|
+
'attributes' => attributes,
|
|
110
|
+
'start_time_unix_nano' => start_time_ns,
|
|
111
|
+
'end_time_unix_nano' => end_time_ns,
|
|
112
|
+
'status' => { 'code' => status }
|
|
113
|
+
}
|
|
114
|
+
@spans << span
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def cache_response(model, messages, response_content, input_tokens = 0, output_tokens = 0)
|
|
118
|
+
prompt_hash = hash_prompt(messages)
|
|
119
|
+
url = URI("#{@base_url}/functions/v1/cache-ai-response")
|
|
120
|
+
|
|
121
|
+
payload = {
|
|
122
|
+
api_key: @api_key,
|
|
123
|
+
prompt_hash: prompt_hash,
|
|
124
|
+
response_content: response_content,
|
|
125
|
+
model: model,
|
|
126
|
+
prompt_content: messages.is_a?(String) ? messages : JSON.generate(messages),
|
|
127
|
+
tokens_used: input_tokens + output_tokens,
|
|
128
|
+
cacheable: true,
|
|
129
|
+
ttl_hours: 24,
|
|
130
|
+
customer_id: @customer_id,
|
|
131
|
+
request_id: @request_id
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
136
|
+
http.use_ssl = true
|
|
137
|
+
http.read_timeout = 5
|
|
138
|
+
|
|
139
|
+
request = Net::HTTP::Post.new(url)
|
|
140
|
+
request['Content-Type'] = 'application/json'
|
|
141
|
+
request.body = JSON.generate(payload)
|
|
142
|
+
|
|
143
|
+
response = http.request(request)
|
|
144
|
+
log("✓ Cached response: #{response.code}") if @debug
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
log("⚠️ Cache error (non-blocking): #{e.message}") if @debug
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def log(message)
|
|
153
|
+
puts "[AgentBill] #{message}" if @debug
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def generate_ids
|
|
157
|
+
existing = Distributed.get_trace_context
|
|
158
|
+
|
|
159
|
+
if existing && existing[:trace_id]
|
|
160
|
+
@trace_id = existing[:trace_id]
|
|
161
|
+
@parent_span_id = existing[:span_id]
|
|
162
|
+
else
|
|
163
|
+
@trace_id = SecureRandom.hex(16)
|
|
164
|
+
@parent_span_id = nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@span_id = SecureRandom.hex(8)
|
|
168
|
+
Distributed.set_trace_context(@trace_id, @span_id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_budget
|
|
172
|
+
url = URI("#{@base_url}/functions/v1/ai-cost-guard-router")
|
|
173
|
+
|
|
174
|
+
payload = {
|
|
175
|
+
api_key: @api_key,
|
|
176
|
+
customer_id: @customer_id,
|
|
177
|
+
model: @model,
|
|
178
|
+
messages: [],
|
|
179
|
+
estimated_tokens: @estimated_tokens
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
payload[:daily_budget_override] = @daily_budget if @daily_budget
|
|
183
|
+
payload[:monthly_budget_override] = @monthly_budget if @monthly_budget
|
|
184
|
+
payload[:agent_id] = @agent_id if @agent_id
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
188
|
+
http.use_ssl = true
|
|
189
|
+
http.read_timeout = 10
|
|
190
|
+
|
|
191
|
+
request = Net::HTTP::Post.new(url)
|
|
192
|
+
request['Content-Type'] = 'application/json'
|
|
193
|
+
request.body = JSON.generate(payload)
|
|
194
|
+
|
|
195
|
+
response = http.request(request)
|
|
196
|
+
|
|
197
|
+
if response.code.to_i >= 500
|
|
198
|
+
log('⚠️ Router server error (failing open)') if @debug
|
|
199
|
+
return { 'allowed' => true, 'reason' => 'Router server error (failed open)' }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if response.code.to_i >= 400
|
|
203
|
+
log("❌ Router rejected: #{response.body}") if @debug
|
|
204
|
+
return { 'allowed' => false, 'reason' => response.body }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
result = JSON.parse(response.body)
|
|
208
|
+
@request_id = result['request_id']
|
|
209
|
+
|
|
210
|
+
if @debug
|
|
211
|
+
if result['allowed']
|
|
212
|
+
log('✓ Budget validation passed')
|
|
213
|
+
else
|
|
214
|
+
log("❌ Budget validation failed: #{result['reason']}")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
result
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
log("⚠️ Router network error: #{e.message} (failing open)") if @debug
|
|
221
|
+
{ 'allowed' => true, 'reason' => 'Router network error (failed open)' }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def export_spans
|
|
226
|
+
return if @spans.empty?
|
|
227
|
+
|
|
228
|
+
url = URI("#{@base_url}/functions/v1/otel-collector")
|
|
229
|
+
|
|
230
|
+
resource_attributes = [
|
|
231
|
+
{ key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
|
|
232
|
+
{ key: 'service.version', value: { stringValue: VERSION } }
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
resource_attributes << { key: 'customer.id', value: { stringValue: @customer_id } } if @customer_id
|
|
236
|
+
resource_attributes << { key: 'agent.id', value: { stringValue: @agent_id } } if @agent_id
|
|
237
|
+
|
|
238
|
+
otlp_spans = @spans.map do |span|
|
|
239
|
+
otlp_span = {
|
|
240
|
+
traceId: span['trace_id'],
|
|
241
|
+
spanId: span['span_id'],
|
|
242
|
+
name: span['name'],
|
|
243
|
+
kind: 1,
|
|
244
|
+
startTimeUnixNano: span['start_time_unix_nano'].to_s,
|
|
245
|
+
endTimeUnixNano: span['end_time_unix_nano'].to_s,
|
|
246
|
+
attributes: (span['attributes'] || {}).map do |k, v|
|
|
247
|
+
value = case v
|
|
248
|
+
when String then { stringValue: v }
|
|
249
|
+
when Integer then { intValue: v }
|
|
250
|
+
when Float then { doubleValue: v }
|
|
251
|
+
else { stringValue: v.to_s }
|
|
252
|
+
end
|
|
253
|
+
{ key: k, value: value }
|
|
254
|
+
end,
|
|
255
|
+
status: span['status']
|
|
256
|
+
}
|
|
257
|
+
otlp_span[:parentSpanId] = span['parent_span_id'] if span['parent_span_id']
|
|
258
|
+
otlp_span
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
payload = {
|
|
262
|
+
resourceSpans: [{
|
|
263
|
+
resource: { attributes: resource_attributes },
|
|
264
|
+
scopeSpans: [{
|
|
265
|
+
scope: { name: 'agentbill', version: VERSION },
|
|
266
|
+
spans: otlp_spans
|
|
267
|
+
}]
|
|
268
|
+
}]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
begin
|
|
272
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
273
|
+
http.use_ssl = true
|
|
274
|
+
http.read_timeout = 10
|
|
275
|
+
|
|
276
|
+
request = Net::HTTP::Post.new(url)
|
|
277
|
+
request['Content-Type'] = 'application/json'
|
|
278
|
+
request['X-API-Key'] = @api_key
|
|
279
|
+
request.body = JSON.generate(payload)
|
|
280
|
+
|
|
281
|
+
response = http.request(request)
|
|
282
|
+
|
|
283
|
+
if @debug
|
|
284
|
+
if response.code.to_i == 200
|
|
285
|
+
log("✓ Exported #{@spans.length} spans to otel-collector")
|
|
286
|
+
else
|
|
287
|
+
log("⚠️ Span export failed: #{response.code}")
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
log("⚠️ Span export error: #{e.message}") if @debug
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def hash_prompt(messages)
|
|
296
|
+
content = messages.is_a?(String) ? messages : JSON.generate(messages)
|
|
297
|
+
Digest::SHA256.hexdigest(content)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
class << self
|
|
302
|
+
# Context manager for tracking AI operations with Cost Guard protection.
|
|
303
|
+
#
|
|
304
|
+
# @example
|
|
305
|
+
# AgentBill.tracing(customer_id: 'cust-123', api_key: 'ab_xxx') do |ctx|
|
|
306
|
+
# response = openai.chat.completions.create(...)
|
|
307
|
+
# AgentBill.signal('chat_completed', revenue: 0.50)
|
|
308
|
+
# end
|
|
309
|
+
#
|
|
310
|
+
# @raise [BudgetExceededError] If daily/monthly budget would be exceeded
|
|
311
|
+
# @raise [RateLimitExceededError] If rate limits would be exceeded
|
|
312
|
+
# @raise [PolicyViolationError] If other policy constraints are violated
|
|
313
|
+
def tracing(customer_id:, api_key: nil, daily_budget: nil, monthly_budget: nil,
|
|
314
|
+
agent_id: nil, base_url: nil, debug: false, model: nil, estimated_tokens: 1000)
|
|
315
|
+
effective_api_key = api_key || ENV['AGENTBILL_API_KEY']
|
|
316
|
+
raise ArgumentError, 'api_key is required. Provide it as argument or set AGENTBILL_API_KEY env var.' unless effective_api_key
|
|
317
|
+
|
|
318
|
+
ctx = TracingContext.new(
|
|
319
|
+
api_key: effective_api_key,
|
|
320
|
+
customer_id: customer_id,
|
|
321
|
+
daily_budget: daily_budget,
|
|
322
|
+
monthly_budget: monthly_budget,
|
|
323
|
+
agent_id: agent_id,
|
|
324
|
+
base_url: base_url,
|
|
325
|
+
debug: debug,
|
|
326
|
+
model: model,
|
|
327
|
+
estimated_tokens: estimated_tokens
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
begin
|
|
331
|
+
ctx.enter
|
|
332
|
+
yield ctx
|
|
333
|
+
rescue BudgetExceededError, RateLimitExceededError, PolicyViolationError
|
|
334
|
+
raise
|
|
335
|
+
rescue StandardError => e
|
|
336
|
+
ctx.exit(e)
|
|
337
|
+
raise
|
|
338
|
+
else
|
|
339
|
+
ctx.exit
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
data/lib/agentbill/version.rb
CHANGED