agentbill-sdk 5.0.1 → 9.4.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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'net/http'
5
+ require 'digest'
6
+ require 'json'
7
+ require 'uri'
8
+ require 'time'
9
+
10
+ require_relative 'distributed'
11
+
12
+ module AgentBill
13
+ # Signal module for tracking business events and linking revenue to AI traces.
14
+ module Signals
15
+ BASE_URL = 'https://api.agentbill.io'
16
+ VERSION = '9.4.0'
17
+
18
+ @config = {}
19
+
20
+ class << self
21
+ attr_accessor :config
22
+
23
+ def set_config(options)
24
+ @config = options
25
+ end
26
+
27
+ def get_config
28
+ @config
29
+ end
30
+ end
31
+ end
32
+
33
+ class << self
34
+ # Emit a business signal/event and link it to AI traces.
35
+ #
36
+ # @param event_name [String] Name of the business event
37
+ # @param options [Hash] Signal options
38
+ # @option options [Float] :revenue Revenue amount
39
+ # @option options [Hash] :metadata Additional metadata
40
+ # @option options [String] :customer_id Customer ID
41
+ # @option options [String] :session_id Session ID
42
+ # @option options [String] :trace_id Trace ID to link to
43
+ # @option options [String] :span_id Span ID to link to
44
+ # @option options [String] :parent_span_id Parent span ID
45
+ # @option options [String] :currency Currency (default: USD)
46
+ # @option options [String] :event_type Event type
47
+ # @option options [Float] :event_value Event value
48
+ # @option options [String] :order_id Order ID to link to (v7.8.0)
49
+ # @option options [String] :order_external_id External order ID (v7.8.0)
50
+ # @option options [Hash] :group_by Business grouping keys (v9.2.0) — session_id, workflow_id, batch_id, correlation_id
51
+ # @option options [String] :idempotency_key Dedup key (v9.2.0) — auto-generated if not provided
52
+ # @option options [Hash] :cost_data Manual cost/usage data (v9.2.0 Phase 3)
53
+ #
54
+ # @return [Hash] Result with success status
55
+ #
56
+ # @example
57
+ # AgentBill.signal('purchase', revenue: 99.99, metadata: { product_id: 'prod-123' })
58
+ def signal(event_name, **options)
59
+ config = Signals.get_config
60
+
61
+ api_key = config[:api_key]
62
+ raise ArgumentError, 'AgentBill not initialized. Call AgentBill.init() first or use AgentBill.tracing context.' unless api_key
63
+
64
+ effective_customer_id = options[:customer_id] || config[:customer_id]
65
+
66
+ # Auto-detect trace context if not provided
67
+ ctx = Distributed.get_trace_context
68
+ trace_id = options[:trace_id]
69
+ parent_span_id = options[:parent_span_id]
70
+
71
+ if ctx
72
+ trace_id ||= ctx[:trace_id]
73
+ parent_span_id ||= ctx[:span_id] unless parent_span_id
74
+ end
75
+
76
+ generated_span_id = options[:span_id] || SecureRandom.hex(8)
77
+ generated_trace_id = trace_id || SecureRandom.hex(16)
78
+ now_ns = (Time.now.to_f * 1_000_000_000).to_i
79
+ timestamp_s = Time.now.to_i
80
+
81
+ # Auto-generate idempotency key
82
+ idempotency_key = options[:idempotency_key] || Digest::SHA256.hexdigest("#{generated_trace_id}:#{event_name}:#{timestamp_s}")[0, 32]
83
+
84
+ # Build OTEL-compliant span attributes
85
+ attributes = [
86
+ { key: 'agentbill.event_name', value: { stringValue: event_name } },
87
+ { key: 'agentbill.is_business_event', value: { boolValue: true } }
88
+ ]
89
+
90
+ attributes << { key: 'agentbill.customer_id', value: { stringValue: effective_customer_id } } if effective_customer_id
91
+ attributes << { key: 'agentbill.agent_id', value: { stringValue: config[:agent_id] } } if config[:agent_id]
92
+
93
+ if options[:revenue]
94
+ attributes << { key: 'agentbill.revenue', value: { doubleValue: options[:revenue] } }
95
+ attributes << { key: 'agentbill.currency', value: { stringValue: options[:currency] || 'USD' } }
96
+ end
97
+
98
+ attributes << { key: 'agentbill.event_value', value: { doubleValue: options[:event_value] } } if options[:event_value]
99
+ attributes << { key: 'agentbill.event_type', value: { stringValue: options[:event_type] } } if options[:event_type]
100
+ attributes << { key: 'agentbill.session_id', value: { stringValue: options[:session_id] } } if options[:session_id]
101
+ attributes << { key: 'agentbill.parent_span_id', value: { stringValue: parent_span_id } } if parent_span_id
102
+ # v7.8.0: Order linking
103
+ attributes << { key: 'agentbill.order_id', value: { stringValue: options[:order_id] } } if options[:order_id]
104
+ attributes << { key: 'agentbill.order_external_id', value: { stringValue: options[:order_external_id] } } if options[:order_external_id]
105
+ attributes << { key: 'agentbill.metadata', value: { stringValue: JSON.generate(options[:metadata]) } } if options[:metadata]
106
+
107
+ # v9.2.0: Idempotency key
108
+ attributes << { key: 'agentbill.idempotency_key', value: { stringValue: idempotency_key } }
109
+
110
+ # v9.2.0: Business grouping (whitelisted keys only)
111
+ grouping_keys = %w[session_id workflow_id batch_id correlation_id].freeze
112
+ if options[:group_by].is_a?(Hash)
113
+ options[:group_by].each do |key, value|
114
+ attributes << { key: "agentbill.#{key}", value: { stringValue: value.to_s } } if grouping_keys.include?(key.to_s)
115
+ end
116
+ end
117
+
118
+ # v9.4.0: enable_cost_tracing flag
119
+ if config[:enable_cost_tracing]
120
+ attributes << { key: 'agentbill.cost_tracing.enabled', value: { boolValue: true } }
121
+ end
122
+
123
+ # v9.2.0: Manual cost (Phase 3 – two-variant model)
124
+ if options[:cost_data].is_a?(Hash)
125
+ cd = options[:cost_data]
126
+ attributes << { key: 'agentbill.cost.provider', value: { stringValue: cd[:vendor] } } if cd[:vendor]
127
+ if cd[:cost].is_a?(Hash)
128
+ attributes << { key: 'agentbill.cost.provided', value: { boolValue: true } }
129
+ attributes << { key: 'agentbill.cost.amount', value: { doubleValue: cd[:cost][:amount] } }
130
+ attributes << { key: 'agentbill.cost.currency', value: { stringValue: cd[:cost][:currency] || 'USD' } }
131
+ end
132
+ if cd[:attributes].is_a?(Hash)
133
+ attributes << { key: 'gen_ai.request.model', value: { stringValue: cd[:attributes][:model] } } if cd[:attributes][:model]
134
+ attributes << { key: 'gen_ai.usage.prompt_tokens', value: { intValue: cd[:attributes][:input_tokens] } } if cd[:attributes][:input_tokens]
135
+ attributes << { key: 'gen_ai.usage.completion_tokens', value: { intValue: cd[:attributes][:output_tokens] } } if cd[:attributes][:output_tokens]
136
+ end
137
+ end
138
+ # Build OTEL payload
139
+ payload = {
140
+ resourceSpans: [{
141
+ resource: {
142
+ attributes: [
143
+ { key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
144
+ { key: 'agentbill.customer_id', value: { stringValue: effective_customer_id || '' } },
145
+ { key: 'agentbill.agent_id', value: { stringValue: config[:agent_id] || '' } }
146
+ ]
147
+ },
148
+ scopeSpans: [{
149
+ scope: { name: 'agentbill.signals', version: Signals::VERSION },
150
+ spans: [{
151
+ traceId: generated_trace_id,
152
+ spanId: generated_span_id,
153
+ parentSpanId: parent_span_id || '',
154
+ name: 'agentbill.trace.signal',
155
+ kind: 1,
156
+ startTimeUnixNano: now_ns.to_s,
157
+ endTimeUnixNano: now_ns.to_s,
158
+ attributes: attributes,
159
+ status: { code: 1 }
160
+ }]
161
+ }]
162
+ }]
163
+ }
164
+
165
+ base_url = config[:base_url] || Signals::BASE_URL
166
+ debug = config[:debug] || false
167
+
168
+ begin
169
+ url = URI("#{base_url}/functions/v1/otel-collector")
170
+ http = Net::HTTP.new(url.host, url.port)
171
+ http.use_ssl = true
172
+ http.read_timeout = 10
173
+
174
+ request = Net::HTTP::Post.new(url)
175
+ request['x-api-key'] = api_key
176
+ request['Content-Type'] = 'application/json'
177
+ request.body = JSON.generate(payload)
178
+
179
+ response = http.request(request)
180
+
181
+ response_data = {}
182
+ body_success = true
183
+ begin
184
+ response_data = JSON.parse(response.body)
185
+ body_success = response_data['success'] != false
186
+ rescue StandardError
187
+ # If can't parse JSON, fall back to HTTP status check
188
+ end
189
+
190
+ is_success = response.code.to_i == 200 && body_success
191
+
192
+ if debug
193
+ if is_success
194
+ puts "[AgentBill] ✓ Signal '#{event_name}' tracked via OTEL"
195
+ puts "[AgentBill] Revenue: $#{'%.2f' % options[:revenue]} #{options[:currency] || 'USD'}" if options[:revenue]
196
+ puts "[AgentBill] Trace ID: #{generated_trace_id}"
197
+ else
198
+ puts "[AgentBill] ⚠️ Signal tracking failed: #{response_data['error'] || response.message}"
199
+ response_data['errors']&.each { |err| puts "[AgentBill] - #{err}" }
200
+ end
201
+ end
202
+
203
+ {
204
+ success: is_success,
205
+ status_code: response.code.to_i,
206
+ trace_id: generated_trace_id,
207
+ span_id: generated_span_id,
208
+ error: is_success ? nil : response_data['error'],
209
+ errors: is_success ? nil : response_data['errors']
210
+ }
211
+ rescue StandardError => e
212
+ puts "[AgentBill] ⚠️ Signal tracking error: #{e.message}" if debug
213
+ {
214
+ success: false,
215
+ error: e.message,
216
+ trace_id: generated_trace_id
217
+ }
218
+ end
219
+ end
220
+
221
+ # Track a conversion event for revenue attribution.
222
+ #
223
+ # @param event_type [String] Type of conversion
224
+ # @param event_value [Float] Value of the conversion
225
+ # @param options [Hash] Additional options
226
+ #
227
+ # @return [Hash] Result with status
228
+ def track_conversion(event_type, event_value, **options)
229
+ signal(
230
+ "conversion_#{event_type}",
231
+ revenue: event_value,
232
+ event_type: event_type,
233
+ event_value: event_value,
234
+ **options
235
+ )
236
+ end
237
+
238
+ # Create a signal explicitly linked to an AI trace for cost attribution.
239
+ #
240
+ # This is the recommended way to link business events to AI costs.
241
+ # Requires trace_id from the AI call you want to attribute costs to.
242
+ # Sets enable_cost_tracing=true automatically.
243
+ #
244
+ # v9.4.0: Added as part of P2 Architecture Alignment (Python parity).
245
+ #
246
+ # @param event_name [String] Name of the business event
247
+ # @param trace_id [String] Trace ID from the AI call
248
+ # @param revenue [Float] Revenue amount
249
+ # @param options [Hash] Additional signal options
250
+ # @return [Hash] Result with status
251
+ #
252
+ # @example
253
+ # AgentBill.cost_attributed_signal('purchase', 'abc123', 99.99, metadata: { product_id: 'prod-123' })
254
+ def cost_attributed_signal(event_name, trace_id:, revenue:, **options)
255
+ config = Signals.get_config
256
+ prev_config = config.dup
257
+ Signals.set_config(config.merge(enable_cost_tracing: true))
258
+
259
+ begin
260
+ signal(
261
+ event_name,
262
+ trace_id: trace_id,
263
+ revenue: revenue,
264
+ **options
265
+ )
266
+ ensure
267
+ Signals.set_config(prev_config)
268
+ end
269
+ end
270
+ end
271
+ 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
 
@@ -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)