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.
@@ -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
@@ -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://uenhjwdtnxtchlmqarjo.supabase.co'
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
- trace_id = SecureRandom.hex(16)
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['Authorization'] = "Bearer #{@api_key}"
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: '1.0.0' },
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
@@ -1,3 +1,3 @@
1
1
  module AgentBill
2
- VERSION = "2.0.1"
2
+ VERSION = "7.17.0"
3
3
  end