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.
@@ -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
@@ -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)