agentbill-sdk 5.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 +29 -0
- data/examples/ollama_basic.rb +81 -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 +68 -11
- 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 +252 -45
- metadata +16 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Perplexity AI Wrapper for AgentBill Ruby SDK
|
|
2
|
+
require_relative 'pricing'
|
|
3
|
+
|
|
4
|
+
module AgentBill
|
|
5
|
+
class PerplexityWrapper
|
|
6
|
+
def initialize(client, config, tracer)
|
|
7
|
+
@client = client
|
|
8
|
+
@config = config
|
|
9
|
+
@tracer = tracer
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def wrap
|
|
13
|
+
original_chat_create = @client.method(:chat_create)
|
|
14
|
+
config = @config
|
|
15
|
+
tracer = @tracer
|
|
16
|
+
|
|
17
|
+
@client.define_singleton_method(:chat_create) do |params|
|
|
18
|
+
model = params[:model] || 'llama-3.1-sonar-small-128k-online'
|
|
19
|
+
messages = params[:messages] || []
|
|
20
|
+
|
|
21
|
+
start_time = Time.now
|
|
22
|
+
span = tracer.start_span('perplexity.chat.completions.create', {
|
|
23
|
+
# ✅ OTEL GenAI compliant attributes
|
|
24
|
+
'gen_ai.system' => 'perplexity',
|
|
25
|
+
'gen_ai.request.model' => model,
|
|
26
|
+
'gen_ai.operation.name' => 'chat',
|
|
27
|
+
# ⚠️ Backward compatibility
|
|
28
|
+
'model' => model,
|
|
29
|
+
'provider' => 'perplexity'
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
response = original_chat_create.call(params)
|
|
34
|
+
latency = ((Time.now - start_time) * 1000).round
|
|
35
|
+
|
|
36
|
+
input_tokens = response.dig(:usage, :prompt_tokens) || 0
|
|
37
|
+
output_tokens = response.dig(:usage, :completion_tokens) || 0
|
|
38
|
+
cost = Pricing.calculate_cost(model, input_tokens, output_tokens, 'perplexity')
|
|
39
|
+
|
|
40
|
+
track_usage(model, 'perplexity', input_tokens, output_tokens, latency, cost, config)
|
|
41
|
+
|
|
42
|
+
span.set_attributes({
|
|
43
|
+
# ✅ OTEL GenAI compliant attributes
|
|
44
|
+
'gen_ai.usage.input_tokens' => input_tokens,
|
|
45
|
+
'gen_ai.usage.output_tokens' => output_tokens,
|
|
46
|
+
'gen_ai.response.id' => response.dig(:id),
|
|
47
|
+
# ⚠️ Backward compatibility
|
|
48
|
+
'response.prompt_tokens' => input_tokens,
|
|
49
|
+
'response.completion_tokens' => output_tokens,
|
|
50
|
+
'latency_ms' => latency,
|
|
51
|
+
'cost' => cost
|
|
52
|
+
})
|
|
53
|
+
span.set_status(0)
|
|
54
|
+
|
|
55
|
+
puts "[AgentBill] ✓ Perplexity chat tracked: $#{format('%.6f', cost)}" if config[:debug]
|
|
56
|
+
response
|
|
57
|
+
rescue => e
|
|
58
|
+
span.set_status(1, e.message)
|
|
59
|
+
raise
|
|
60
|
+
ensure
|
|
61
|
+
span.finish
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@client
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def track_usage(model, provider, input_tokens, output_tokens, latency, cost, config)
|
|
71
|
+
uri = URI("#{config[:base_url] || 'https://api.agentbill.io'}/functions/v1/track-ai-usage")
|
|
72
|
+
|
|
73
|
+
payload = {
|
|
74
|
+
api_key: config[:api_key],
|
|
75
|
+
customer_id: config[:customer_id],
|
|
76
|
+
agent_id: config[:agent_id],
|
|
77
|
+
event_name: 'ai_request',
|
|
78
|
+
model: model,
|
|
79
|
+
provider: provider,
|
|
80
|
+
prompt_tokens: input_tokens,
|
|
81
|
+
completion_tokens: output_tokens,
|
|
82
|
+
latency_ms: latency,
|
|
83
|
+
cost: cost
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
88
|
+
http.use_ssl = true
|
|
89
|
+
http.read_timeout = 10
|
|
90
|
+
|
|
91
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
92
|
+
request['Content-Type'] = 'application/json'
|
|
93
|
+
request.body = payload.to_json
|
|
94
|
+
|
|
95
|
+
http.request(request)
|
|
96
|
+
rescue => e
|
|
97
|
+
puts "[AgentBill] Tracking failed: #{e.message}" if config[:debug]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# AgentBill SDK Pricing - LOCAL ESTIMATION ONLY
|
|
2
|
+
#
|
|
3
|
+
# IMPORTANT: Cost calculation is now 100% SERVER-SIDE
|
|
4
|
+
# The server uses the model_pricing database table (synced by sync-model-pricing).
|
|
5
|
+
# This module is kept ONLY for local estimation/display purposes.
|
|
6
|
+
# The AUTHORITATIVE cost is always calculated server-side.
|
|
7
|
+
|
|
8
|
+
module AgentBill
|
|
9
|
+
module Pricing
|
|
10
|
+
# Default fallback rates for local estimation only
|
|
11
|
+
DEFAULT_INPUT_PER_1M = 1.0
|
|
12
|
+
DEFAULT_OUTPUT_PER_1M = 2.0
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# LOCAL ESTIMATION ONLY - for display purposes.
|
|
16
|
+
# Actual cost is calculated server-side using model_pricing database.
|
|
17
|
+
def calculate_cost(model, input_tokens, output_tokens, provider = 'openai')
|
|
18
|
+
# Ollama is free
|
|
19
|
+
return 0.0 if provider == 'ollama' || model.to_s.start_with?('ollama/')
|
|
20
|
+
|
|
21
|
+
# Simple default estimate - server calculates actual cost
|
|
22
|
+
input_cost = (input_tokens.to_f / 1_000_000) * DEFAULT_INPUT_PER_1M
|
|
23
|
+
output_cost = (output_tokens.to_f / 1_000_000) * DEFAULT_OUTPUT_PER_1M
|
|
24
|
+
input_cost + output_cost
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# LOCAL ESTIMATION ONLY for image generation
|
|
28
|
+
def calculate_image_cost(model, size, quality = 'standard')
|
|
29
|
+
if model.include?('dall-e-3')
|
|
30
|
+
quality == 'hd' ? 0.08 : 0.04
|
|
31
|
+
else
|
|
32
|
+
0.02
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# LOCAL ESTIMATION ONLY for audio
|
|
37
|
+
def calculate_audio_cost(model, duration_seconds: 0, chars: 0)
|
|
38
|
+
if model == 'whisper-1'
|
|
39
|
+
(duration_seconds.to_f / 60.0) * 0.006
|
|
40
|
+
elsif model.start_with?('tts-')
|
|
41
|
+
(chars.to_f / 1_000_000) * 15.0
|
|
42
|
+
else
|
|
43
|
+
0.0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def embedding_model?(model)
|
|
48
|
+
model.downcase.include?('embedding') || model.downcase.include?('embed')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module AgentBill
|
|
8
|
+
# Signal Types Resource - CRUD operations for signal type management
|
|
9
|
+
# v7.15.0: New resource for programmatic signal type management
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# client = AgentBill::Client.new(api_key: 'agb_...')
|
|
13
|
+
#
|
|
14
|
+
# # Create signal type
|
|
15
|
+
# st = client.signal_types.create(name: 'ai_completion', category: 'activity')
|
|
16
|
+
#
|
|
17
|
+
# # List all
|
|
18
|
+
# signal_types = client.signal_types.list
|
|
19
|
+
#
|
|
20
|
+
# # Get by ID
|
|
21
|
+
# st = client.signal_types.get(signal_type_id)
|
|
22
|
+
#
|
|
23
|
+
# # Update
|
|
24
|
+
# client.signal_types.update(signal_type_id, description: 'Updated')
|
|
25
|
+
#
|
|
26
|
+
# # Delete
|
|
27
|
+
# client.signal_types.delete(signal_type_id)
|
|
28
|
+
#
|
|
29
|
+
class SignalTypesResource
|
|
30
|
+
def initialize(config)
|
|
31
|
+
@base_url = config[:base_url] || 'https://api.agentbill.io'
|
|
32
|
+
@api_key = config[:api_key]
|
|
33
|
+
@debug = config[:debug] || false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# List signal types with optional filters
|
|
37
|
+
#
|
|
38
|
+
# @param category [String, nil] Filter by category ('activity' or 'outcome')
|
|
39
|
+
# @param include_global [Boolean] Include global signal types (default: true)
|
|
40
|
+
# @return [Hash] Response with :data array and :summary
|
|
41
|
+
def list(category: nil, include_global: true)
|
|
42
|
+
uri = URI("#{@base_url}/functions/v1/api-signal-types")
|
|
43
|
+
params = {}
|
|
44
|
+
params[:category] = category if category
|
|
45
|
+
params[:include_global] = 'false' unless include_global
|
|
46
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
47
|
+
|
|
48
|
+
response = make_request(:get, uri)
|
|
49
|
+
handle_response(response, 'list signal types')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create a new signal type
|
|
53
|
+
#
|
|
54
|
+
# @param name [String] Signal type name (required)
|
|
55
|
+
# @param category [String] Category - 'activity' or 'outcome' (default: 'activity')
|
|
56
|
+
# @param description [String, nil] Description
|
|
57
|
+
# @param unit [String, nil] Unit of measurement
|
|
58
|
+
# @param default_value [Float, nil] Default value
|
|
59
|
+
# @param external_id [String, nil] Your external ID
|
|
60
|
+
# @param expected_metrics [Hash, nil] Expected metrics configuration
|
|
61
|
+
# @param revenue_formula [String, nil] Revenue formula
|
|
62
|
+
# @param validation_rules [Hash, nil] Validation rules
|
|
63
|
+
# @return [Hash] Created signal type
|
|
64
|
+
def create(name:, category: 'activity', description: nil, unit: nil, default_value: nil,
|
|
65
|
+
external_id: nil, expected_metrics: nil, revenue_formula: nil, validation_rules: nil)
|
|
66
|
+
payload = { name: name, category: category }
|
|
67
|
+
payload[:description] = description if description
|
|
68
|
+
payload[:unit] = unit if unit
|
|
69
|
+
payload[:default_value] = default_value if default_value
|
|
70
|
+
payload[:external_id] = external_id if external_id
|
|
71
|
+
payload[:expected_metrics] = expected_metrics if expected_metrics
|
|
72
|
+
payload[:revenue_formula] = revenue_formula if revenue_formula
|
|
73
|
+
payload[:validation_rules] = validation_rules if validation_rules
|
|
74
|
+
|
|
75
|
+
uri = URI("#{@base_url}/functions/v1/api-signal-types")
|
|
76
|
+
response = make_request(:post, uri, payload)
|
|
77
|
+
handle_response(response, 'create signal type')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get a signal type by ID or external_id
|
|
81
|
+
#
|
|
82
|
+
# @param signal_type_id [String] Signal type UUID or external_id
|
|
83
|
+
# @param by_external_id [Boolean] If true, treat signal_type_id as external_id
|
|
84
|
+
# @return [Hash] Signal type object
|
|
85
|
+
def get(signal_type_id, by_external_id: false)
|
|
86
|
+
uri = URI("#{@base_url}/functions/v1/api-signal-types")
|
|
87
|
+
param = by_external_id ? :external_id : :id
|
|
88
|
+
uri.query = URI.encode_www_form({ param => signal_type_id })
|
|
89
|
+
|
|
90
|
+
response = make_request(:get, uri)
|
|
91
|
+
handle_response(response, 'get signal type')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Update a signal type
|
|
95
|
+
#
|
|
96
|
+
# @param signal_type_id [String] Signal type UUID
|
|
97
|
+
# @param name [String, nil] New name
|
|
98
|
+
# @param description [String, nil] New description
|
|
99
|
+
# @param category [String, nil] New category
|
|
100
|
+
# @param unit [String, nil] New unit
|
|
101
|
+
# @param default_value [Float, nil] New default value
|
|
102
|
+
# @param external_id [String, nil] New external ID
|
|
103
|
+
# @param expected_metrics [Hash, nil] New expected metrics
|
|
104
|
+
# @param revenue_formula [String, nil] New revenue formula
|
|
105
|
+
# @param validation_rules [Hash, nil] New validation rules
|
|
106
|
+
# @param is_active [Boolean, nil] Active status
|
|
107
|
+
# @return [Hash] Updated signal type
|
|
108
|
+
def update(signal_type_id, name: nil, description: nil, category: nil, unit: nil,
|
|
109
|
+
default_value: nil, external_id: nil, expected_metrics: nil,
|
|
110
|
+
revenue_formula: nil, validation_rules: nil, is_active: nil)
|
|
111
|
+
payload = { id: signal_type_id }
|
|
112
|
+
payload[:name] = name if name
|
|
113
|
+
payload[:description] = description if description
|
|
114
|
+
payload[:category] = category if category
|
|
115
|
+
payload[:unit] = unit if unit
|
|
116
|
+
payload[:default_value] = default_value if default_value
|
|
117
|
+
payload[:external_id] = external_id if external_id
|
|
118
|
+
payload[:expected_metrics] = expected_metrics if expected_metrics
|
|
119
|
+
payload[:revenue_formula] = revenue_formula if revenue_formula
|
|
120
|
+
payload[:validation_rules] = validation_rules if validation_rules
|
|
121
|
+
payload[:is_active] = is_active unless is_active.nil?
|
|
122
|
+
|
|
123
|
+
uri = URI("#{@base_url}/functions/v1/api-signal-types")
|
|
124
|
+
response = make_request(:patch, uri, payload)
|
|
125
|
+
handle_response(response, 'update signal type')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Delete a signal type
|
|
129
|
+
#
|
|
130
|
+
# @param signal_type_id [String] Signal type UUID to delete
|
|
131
|
+
# @return [Hash] Deletion result
|
|
132
|
+
def delete(signal_type_id)
|
|
133
|
+
uri = URI("#{@base_url}/functions/v1/api-signal-types")
|
|
134
|
+
response = make_request(:delete, uri, { id: signal_type_id })
|
|
135
|
+
handle_response(response, 'delete signal type')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def make_request(method, uri, payload = nil)
|
|
141
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
142
|
+
http.use_ssl = true
|
|
143
|
+
|
|
144
|
+
request = case method
|
|
145
|
+
when :get
|
|
146
|
+
Net::HTTP::Get.new(uri)
|
|
147
|
+
when :post
|
|
148
|
+
Net::HTTP::Post.new(uri)
|
|
149
|
+
when :patch
|
|
150
|
+
Net::HTTP::Patch.new(uri)
|
|
151
|
+
when :delete
|
|
152
|
+
Net::HTTP::Delete.new(uri)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
request['Content-Type'] = 'application/json'
|
|
156
|
+
request['X-API-Key'] = @api_key
|
|
157
|
+
request.body = payload.to_json if payload
|
|
158
|
+
|
|
159
|
+
puts "[AgentBill] #{method.upcase} #{uri}" if @debug
|
|
160
|
+
|
|
161
|
+
http.request(request)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_response(response, operation)
|
|
165
|
+
body = response.body ? JSON.parse(response.body) : {}
|
|
166
|
+
|
|
167
|
+
case response.code.to_i
|
|
168
|
+
when 200, 201, 204
|
|
169
|
+
body
|
|
170
|
+
when 404
|
|
171
|
+
raise "Signal type not found"
|
|
172
|
+
when 409
|
|
173
|
+
raise "Signal type already exists: #{body['error']}"
|
|
174
|
+
else
|
|
175
|
+
raise "Failed to #{operation}: #{body['error'] || response.body}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -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
|
|
|
@@ -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)
|