smith-agents 0.8.0 → 0.10.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -0
  3. data/docs/CONFIGURATION.md +5 -5
  4. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  5. data/lib/smith/agent/invocation_preparation.rb +24 -0
  6. data/lib/smith/agent/lifecycle.rb +4 -2
  7. data/lib/smith/agent/provider_attempt.rb +10 -4
  8. data/lib/smith/agent/provider_call_timing.rb +74 -0
  9. data/lib/smith/agent/provider_completion.rb +27 -9
  10. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  11. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  12. data/lib/smith/agent/usage_traces.rb +49 -0
  13. data/lib/smith/agent/usage_tracking.rb +23 -20
  14. data/lib/smith/attribution.rb +115 -0
  15. data/lib/smith/event.rb +10 -2
  16. data/lib/smith/events/bus.rb +63 -5
  17. data/lib/smith/events/step_completed.rb +3 -0
  18. data/lib/smith/events/step_failed.rb +25 -0
  19. data/lib/smith/events/subscription.rb +8 -0
  20. data/lib/smith/tool/capture.rb +9 -1
  21. data/lib/smith/tool.rb +10 -1
  22. data/lib/smith/trace/logger.rb +1 -0
  23. data/lib/smith/trace/memory.rb +40 -4
  24. data/lib/smith/trace/open_telemetry.rb +47 -3
  25. data/lib/smith/trace.rb +24 -5
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/composite/effects.rb +64 -33
  28. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  29. data/lib/smith/workflow/deterministic_step.rb +14 -1
  30. data/lib/smith/workflow/dsl.rb +25 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  32. data/lib/smith/workflow/event_integration.rb +48 -1
  33. data/lib/smith/workflow/execution.rb +8 -0
  34. data/lib/smith/workflow/fanout_execution.rb +1 -1
  35. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  36. data/lib/smith/workflow/parallel_execution.rb +6 -0
  37. data/lib/smith/workflow/persistence.rb +33 -1
  38. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  39. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  40. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  41. data/lib/smith/workflow/step_completion.rb +9 -0
  42. data/lib/smith/workflow/step_context.rb +46 -0
  43. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  44. data/lib/smith/workflow/usage_entry.rb +32 -4
  45. data/lib/smith/workflow.rb +26 -1
  46. data/lib/smith.rb +7 -2
  47. metadata +5 -1
@@ -2,31 +2,33 @@
2
2
 
3
3
  require_relative "completion_usage_recording"
4
4
  require_relative "usage_entry_recording"
5
+ require_relative "usage_traces"
5
6
 
6
7
  module Smith
7
8
  class Agent
8
9
  module UsageTracking
9
10
  include CompletionUsageRecording
10
11
  include UsageEntryRecording
12
+ include UsageTraces
11
13
 
12
14
  private
13
15
 
14
- def account_failed_attempt(error, model_reference, agent_class)
16
+ def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
15
17
  return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
16
18
 
17
19
  input = error.input_tokens
18
20
  output = error.output_tokens
19
21
  return unless input.is_a?(Integer) && output.is_a?(Integer)
20
22
 
21
- record_failed_usage(agent_class, model_reference, input, output, :failed_attempt)
23
+ record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
22
24
  end
23
25
 
24
- def account_completed_prefix(agent_class, model_reference, messages)
26
+ def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
25
27
  completion = Completion.from_messages(response: nil, messages: messages)
26
- record_completion_usage(agent_class, completion, :partial_attempt, model_reference)
28
+ record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
27
29
  end
28
30
 
29
- def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_kind)
31
+ def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
30
32
  model_reference = coerce_model_reference(model_reference)
31
33
  cost = Smith::Pricing.compute_cost(
32
34
  model: model_reference.model_id,
@@ -44,10 +46,10 @@ module Smith
44
46
  )
45
47
  Thread.current[:smith_failed_agent_results] ||= []
46
48
  Thread.current[:smith_failed_agent_results] << agent_result
47
- record_usage(agent_class, agent_result, attempt_kind, model_reference)
49
+ record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
48
50
  end
49
51
 
50
- def snapshot_and_finalize(agent_class, completion, model_reference)
52
+ def snapshot_and_finalize(agent_class, completion, model_reference, attempt_id: nil)
51
53
  model_reference = coerce_model_reference(model_reference)
52
54
  agent_result = Workflow::AgentResult.new(
53
55
  content: completion.content,
@@ -59,8 +61,7 @@ module Smith
59
61
  )
60
62
  Thread.current[:smith_last_agent_result] = agent_result
61
63
  emit_token_usage(agent_result)
62
- compute_agent_cost(agent_result)
63
- record_completion_usage(agent_class, completion, :completed_attempt, model_reference)
64
+ account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
64
65
 
65
66
  agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
66
67
  raise_blank_output!(agent_class, agent_result)
@@ -83,18 +84,20 @@ module Smith
83
84
  false
84
85
  end
85
86
 
86
- def emit_token_usage(agent_result)
87
- return unless agent_result.usage_known?
88
-
89
- Smith::Trace.record(
90
- type: :token_usage,
91
- data: {
92
- input_tokens: agent_result.input_tokens,
93
- output_tokens: agent_result.output_tokens,
94
- model: agent_result.model_used,
95
- provider: agent_result.provider_used
96
- }.compact
87
+ # Records one usage entry per provider response and settles the
88
+ # invocation's cost from the recorded per-response sum: that sum is
89
+ # what tiered catalogs bill, so it becomes agent_result.cost (which
90
+ # budget settlement and result surfaces read) and the recorded entries,
91
+ # the budget ledger, and the :cost trace all agree. The trace emits
92
+ # only for fully metered, fully priced invocations, so a partial
93
+ # figure is never presented as the invocation cost; the partial sum
94
+ # still settles the budget because it is what was verifiably billed.
95
+ def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
96
+ invocation_cost, fully_priced = record_completion_usage(
97
+ agent_class, completion, :completed_attempt, model_reference, attempt_id:
97
98
  )
99
+ agent_result.cost = invocation_cost
100
+ emit_cost_trace(agent_result, invocation_cost) if fully_priced
98
101
  end
99
102
 
100
103
  def compute_agent_cost(agent_result)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # Ambient execution attribution: an immutable, thread-local description of
5
+ # where execution currently is (run identity, transition, fan-out branch,
6
+ # optimizer round). Workflow execution scopes install it; observability
7
+ # consumers (traces, events, usage recording) read it, so emitted facts
8
+ # carry correlation without any consumer knowing about the workflow.
9
+ #
10
+ # Attribution values are opaque identifiers, never content: the trace
11
+ # content policy does not treat them as payload. `execution_key` defaults
12
+ # to the workflow's persistence key during persisted runs; hosts running
13
+ # non-persisted workflows can seed an outer scope explicitly:
14
+ #
15
+ # Smith::Attribution.with(execution_key: "host-run-42") { workflow.run! }
16
+ #
17
+ # Installation inside workflow internals is a plain assignment
18
+ # (Attribution.install) because restoration there is owned by the
19
+ # surrounding ThreadContextSnapshot, which tracks the attribution thread
20
+ # key alongside the other per-step thread state.
21
+ module Attribution
22
+ THREAD_KEY = :smith_attribution
23
+
24
+ Context = Data.define(:execution_key, :transition, :from, :to, :branch_key, :round, :workflow) do
25
+ # Overlay semantics: nil overrides are ignored so an inner scope can
26
+ # only add or replace attribution, never blank an outer value.
27
+ def merge(**overrides)
28
+ filtered = overrides.compact
29
+ return self if filtered.empty?
30
+
31
+ override(**filtered)
32
+ end
33
+
34
+ # Replacement semantics: sets the given fields verbatim, nil included,
35
+ # so a scope that owns a field can reset it (a step whose transition
36
+ # declares no `from` must not inherit an enclosing step's `from`).
37
+ # Built on to_h, not Data#with, which requires Ruby 3.3 while the gem
38
+ # supports 3.2.
39
+ def override(**fields)
40
+ self.class.new(**to_h, **fields)
41
+ end
42
+
43
+ def to_fields
44
+ {
45
+ execution_key: execution_key,
46
+ transition: transition,
47
+ from: from,
48
+ to: to,
49
+ branch_key: branch_key,
50
+ round: round,
51
+ workflow: workflow
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ EMPTY = Context.new(
57
+ execution_key: nil, transition: nil, from: nil, to: nil, branch_key: nil, round: nil, workflow: nil
58
+ )
59
+
60
+ class << self
61
+ def current
62
+ Thread.current[THREAD_KEY]
63
+ end
64
+
65
+ def ambient
66
+ current || EMPTY
67
+ end
68
+
69
+ # The compacted attribution fields, for merging into emitted payloads.
70
+ def current_fields
71
+ context = current
72
+ context ? context.to_fields : {}
73
+ end
74
+
75
+ # Plain installation with no restoration: callers own restoration,
76
+ # either through ThreadContextSnapshot (workflow internals) or an
77
+ # enclosing #with / #carrying block.
78
+ def install(context)
79
+ Thread.current[THREAD_KEY] = context
80
+ end
81
+
82
+ # Host-facing scope: overlays the ambient attribution for the block.
83
+ def with(**overrides, &block)
84
+ raise ArgumentError, "block required" unless block
85
+
86
+ swap(ambient.merge(**overrides), &block)
87
+ end
88
+
89
+ # Cross-thread propagation: installs a context captured on another
90
+ # thread (or nil, clearing any stale value on a pooled thread) for the
91
+ # duration of the block.
92
+ def carrying(context, &block)
93
+ raise ArgumentError, "block required" unless block
94
+
95
+ swap(context, &block)
96
+ end
97
+
98
+ private
99
+
100
+ # Matches the gem's scoped thread-state idiom: install and restore run
101
+ # interrupt-deferred, the block itself runs interruptible.
102
+ def swap(context, &block)
103
+ previous = Thread.current[THREAD_KEY]
104
+ Thread.handle_interrupt(Object => :never) do
105
+ Thread.current[THREAD_KEY] = context
106
+ begin
107
+ Thread.handle_interrupt(Object => :immediate, &block)
108
+ ensure
109
+ Thread.current[THREAD_KEY] = previous
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/smith/event.rb CHANGED
@@ -3,9 +3,17 @@
3
3
  require "dry-struct"
4
4
  require "securerandom"
5
5
 
6
+ require_relative "attribution"
7
+
6
8
  module Smith
7
9
  class Event < Dry::Struct
8
- attribute(:execution_id, Types::String.default { SecureRandom.uuid })
9
- attribute(:trace_id, Types::String.default { SecureRandom.uuid })
10
+ # Both ids default to the ambient Smith::Attribution execution key, so
11
+ # events emitted under one persisted run (or one host-seeded
12
+ # Attribution scope) share one identity a host can group by. Outside
13
+ # any scope, each event falls back to its own random UUID, so
14
+ # non-persisted, unseeded runs have no shared event identity. Callers
15
+ # may always pass explicit values.
16
+ attribute(:execution_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
17
+ attribute(:trace_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
10
18
  end
11
19
  end
@@ -18,19 +18,43 @@ module Smith
18
18
  end
19
19
  end
20
20
 
21
+ REGISTRY_MUTEX = Mutex.new
22
+ private_constant :REGISTRY_MUTEX
23
+
21
24
  class << self
25
+ # Registration-ordered snapshot of the live subscriptions. Cancelled
26
+ # subscriptions are detached from the registry, so this reflects only
27
+ # what will actually receive events.
22
28
  def subscriptions
23
- @subscriptions ||= []
29
+ snapshot = REGISTRY_MUTEX.synchronize { registry.values.flatten }
30
+ snapshot.sort_by!(&:sequence_number)
24
31
  end
25
32
 
26
33
  def on(event_class, **opts, &block)
27
34
  sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
28
- subscriptions << sub
35
+ REGISTRY_MUTEX.synchronize do
36
+ @sequence = (@sequence || 0) + 1
37
+ sub.sequence_number = @sequence
38
+ (registry[event_class] ||= []) << sub
39
+ end
29
40
  sub
30
41
  end
31
42
 
43
+ # Removes a subscription from the registry. Called by
44
+ # Subscription#cancel; safe to call more than once.
45
+ def detach(subscription)
46
+ REGISTRY_MUTEX.synchronize do
47
+ bucket = registry[subscription.event_class]
48
+ next unless bucket
49
+
50
+ bucket.delete(subscription)
51
+ registry.delete(subscription.event_class) if bucket.empty?
52
+ end
53
+ nil
54
+ end
55
+
32
56
  def emit(event)
33
- subscriptions.each { |sub| dispatch_to(sub, event) }
57
+ matching_subscriptions(event).each { |sub| dispatch_to(sub, event) }
34
58
  end
35
59
 
36
60
  def within
@@ -41,14 +65,48 @@ module Smith
41
65
  end
42
66
 
43
67
  def reset!
44
- @subscriptions = []
68
+ REGISTRY_MUTEX.synchronize do
69
+ @registry = {}
70
+ @sequence = 0
71
+ end
45
72
  end
46
73
 
47
74
  private
48
75
 
76
+ def registry
77
+ @registry ||= {}
78
+ end
79
+
80
+ # Subscriptions live in per-class buckets so one emit touches only the
81
+ # buckets for the event's ancestors instead of scanning every
82
+ # subscription; `is_a?` dispatch semantics are preserved exactly
83
+ # because a subscription matches iff its registered class or module is
84
+ # among the event class's ancestors. The merged candidates are ordered
85
+ # by registration sequence, keeping subscription-order dispatch.
86
+ # Handlers run outside the registry lock so a handler may subscribe or
87
+ # cancel without deadlocking.
88
+ def matching_subscriptions(event)
89
+ # singleton_class.ancestors, not class.ancestors: it additionally
90
+ # covers modules mixed into the event instance via extend, which
91
+ # `is_a?` matched before the bucketed registry existed. Immediates
92
+ # (Integer, Symbol, Float) have no singleton class; they also cannot
93
+ # be extended, so their class ancestors are the complete `is_a?` set.
94
+ ancestors = begin
95
+ event.singleton_class.ancestors
96
+ rescue TypeError
97
+ event.class.ancestors
98
+ end
99
+ candidates = REGISTRY_MUTEX.synchronize do
100
+ ancestors.each_with_object([]) do |ancestor, found|
101
+ bucket = registry[ancestor]
102
+ found.concat(bucket) if bucket
103
+ end
104
+ end
105
+ candidates.sort_by!(&:sequence_number)
106
+ end
107
+
49
108
  def dispatch_to(sub, event)
50
109
  return if sub.cancelled?
51
- return unless event.is_a?(sub.event_class)
52
110
  return if sub.predicate && !sub.predicate.call(event)
53
111
 
54
112
  sub.handler.call(event)
@@ -6,6 +6,9 @@ module Smith
6
6
  attribute :transition, Types::Strict::Symbol
7
7
  attribute :from, Types::Strict::Symbol.optional
8
8
  attribute :to, Types::Strict::Symbol
9
+ # The emitting workflow's class name; distinguishes nested-child steps
10
+ # from parent steps under the shared root execution identity.
11
+ attribute :workflow, Types::Strict::String.optional.default(nil)
9
12
  end
10
13
  end
11
14
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ # Emitted from both step-failure paths (step-body failure and an
6
+ # unresolved transition routed to :fail), closing the success-only
7
+ # observation gap for failures that reach step handling. Terminal errors
8
+ # raised outside it (an unresolved transition with no :fail transition,
9
+ # transition-budget exhaustion, origin validation) still re-raise
10
+ # without a StepFailed. Carries bounded classification only, never raw
11
+ # error messages; `error_family` uses FailureRecord's taxonomy
12
+ # ("agent_error", "deadline_exceeded", ..., "other") and `retryable` is
13
+ # nil when the error does not declare retryability.
14
+ class StepFailed < Smith::Event
15
+ attribute :transition, Types::Strict::Symbol
16
+ attribute :from, Types::Strict::Symbol.optional
17
+ attribute :to, Types::Strict::Symbol.optional
18
+ attribute :error_class, Types::Strict::String
19
+ attribute :error_family, Types::Strict::String
20
+ attribute :retryable, Types::Strict::Bool.optional
21
+ # See StepCompleted#workflow.
22
+ attribute :workflow, Types::Strict::String.optional.default(nil)
23
+ end
24
+ end
25
+ end
@@ -4,6 +4,9 @@ module Smith
4
4
  module Events
5
5
  class Subscription
6
6
  attr_reader :event_class, :handler, :predicate
7
+ # Internal registration order, assigned by Events.on; dispatch and the
8
+ # subscriptions snapshot sort by it.
9
+ attr_accessor :sequence_number
7
10
 
8
11
  def initialize(event_class, handler:, predicate: nil)
9
12
  @event_class = event_class
@@ -12,8 +15,13 @@ module Smith
12
15
  @cancelled = false
13
16
  end
14
17
 
18
+ # Cancelling flags the subscription and detaches it from the registry
19
+ # (so it cannot leak). The flag check against an in-flight emit
20
+ # snapshot is best-effort: a dispatch already past the check may still
21
+ # deliver once after cancel returns.
15
22
  def cancel
16
23
  @cancelled = true
24
+ Events.detach(self)
17
25
  end
18
26
 
19
27
  def cancelled?
@@ -54,8 +54,16 @@ module Smith
54
54
  capture_failure(e, strict, reason: :capture_block_failed)
55
55
  end
56
56
 
57
+ # tool_call_id is additive only when present (a provider-batch
58
+ # invocation is ambient), so captures from direct invocations and
59
+ # pre-existing persisted payloads keep their exact two-key shape.
57
60
  def append_capture(collector, captured, strict)
58
- collector.call({ tool: name.to_s, captured: captured }) if strict || captured
61
+ return unless strict || captured
62
+
63
+ entry = { tool: name.to_s, captured: captured }
64
+ tool_call_id = self.class.current_invocation&.tool_call_id
65
+ entry[:tool_call_id] = tool_call_id if tool_call_id
66
+ collector.call(entry)
59
67
  rescue StandardError => e
60
68
  capture_failure(e, strict, reason: :collector_failed)
61
69
  end
data/lib/smith/tool.rb CHANGED
@@ -165,9 +165,18 @@ module Smith
165
165
  end
166
166
 
167
167
  def emit_tool_trace(kwargs, result, duration)
168
+ # tool_call_id is the provider's correlation id for this invocation
169
+ # (present only when the call came from a provider batch); it lets a
170
+ # host join this trace to its own per-invocation records.
168
171
  Smith::Trace.record(
169
172
  type: :tool_call,
170
- data: { tool: name, args: kwargs, result: result, duration: duration },
173
+ data: {
174
+ tool: name,
175
+ args: kwargs,
176
+ result: result,
177
+ duration: duration,
178
+ tool_call_id: self.class.current_invocation&.tool_call_id
179
+ }.compact,
171
180
  sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
172
181
  )
173
182
  end
@@ -7,6 +7,7 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
+ provider_call: :trace_provider_calls,
10
11
  cost: :trace_cost,
11
12
  normalizer_decision: :trace_normalizer
12
13
  }.freeze
@@ -7,26 +7,62 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
+ provider_call: :trace_provider_calls,
10
11
  cost: :trace_cost,
11
12
  normalizer_decision: :trace_normalizer
12
13
  }.freeze
13
14
 
14
15
  CONTENT_KEYS = %i[content prompt response args result].freeze
15
16
 
16
- attr_reader :traces
17
+ # Generous enough that test and development runs never hit it; a bound
18
+ # exists at all so a long-lived process with parallel branches cannot
19
+ # grow this adapter without limit.
20
+ DEFAULT_LIMIT = 10_000
17
21
 
18
- def initialize
22
+ attr_reader :traces, :limit
23
+
24
+ def initialize(limit: DEFAULT_LIMIT)
25
+ unless limit.is_a?(Integer) && limit.positive?
26
+ raise ArgumentError, "Smith::Trace::Memory limit must be a positive integer, got #{limit.inspect}"
27
+ end
28
+
29
+ @limit = limit
19
30
  @traces = []
31
+ @dropped_count = 0
32
+ @mutex = Mutex.new
20
33
  end
21
34
 
22
35
  def record(type:, data:)
23
36
  return unless type_enabled?(type)
24
37
 
25
- @traces << { type: type, data: filter_content(data) }
38
+ entry = { type: type, data: filter_content(data) }
39
+ @mutex.synchronize do
40
+ if @traces.length >= @limit
41
+ @dropped_count += 1
42
+ else
43
+ @traces << entry
44
+ end
45
+ end
46
+ end
47
+
48
+ # Entries rejected because the adapter was full. Zero in any healthy
49
+ # test run; a growing value means the limit needs raising or the
50
+ # process needs a clear!.
51
+ def dropped_count
52
+ @mutex.synchronize { @dropped_count }
53
+ end
54
+
55
+ # A consistent copy for readers that may race concurrent recording;
56
+ # #traces stays the live array for compatibility.
57
+ def snapshot
58
+ @mutex.synchronize { @traces.dup }
26
59
  end
27
60
 
28
61
  def clear!
29
- @traces = []
62
+ @mutex.synchronize do
63
+ @traces = []
64
+ @dropped_count = 0
65
+ end
30
66
  end
31
67
 
32
68
  private
@@ -7,7 +7,9 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
- cost: :trace_cost
10
+ provider_call: :trace_provider_calls,
11
+ cost: :trace_cost,
12
+ normalizer_decision: :trace_normalizer
11
13
  }.freeze
12
14
 
13
15
  CONTENT_KEYS = %i[content prompt response args result].freeze
@@ -23,18 +25,60 @@ module Smith
23
25
  )
24
26
  end
25
27
 
28
+ # Smith trace events describe operations that already finished, so the
29
+ # span is created retroactively: when the event carries a duration
30
+ # (:tool_call seconds, :provider_call milliseconds) the span's start is
31
+ # backdated by it and the span duration is real; otherwise the span is
32
+ # an instant. Uses only the documented opentelemetry-api surface
33
+ # (Tracer#start_span with start_timestamp, Span#finish with
34
+ # end_timestamp) so any SDK the host installs applies.
26
35
  def record(type:, data:)
27
36
  return unless @tracer
28
37
  return unless type_enabled?(type)
29
38
 
30
39
  filtered = filter_content(data)
31
- @tracer.in_span("smith.#{type}") do |span|
32
- filtered.each { |key, value| span.set_attribute("smith.#{key}", value.to_s) }
40
+ finished_at = Time.now
41
+ span = @tracer.start_span("smith.#{type}", start_timestamp: span_start(filtered, finished_at))
42
+ begin
43
+ apply_attributes(span, filtered)
44
+ ensure
45
+ span.finish(end_timestamp: finished_at)
33
46
  end
34
47
  end
35
48
 
36
49
  private
37
50
 
51
+ def span_start(data, finished_at)
52
+ seconds = duration_seconds(data)
53
+ seconds ? finished_at - seconds : finished_at
54
+ end
55
+
56
+ def duration_seconds(data)
57
+ return data[:duration].to_f if data[:duration].is_a?(Numeric)
58
+ return data[:duration_ms] / 1000.0 if data[:duration_ms].is_a?(Numeric)
59
+
60
+ nil
61
+ end
62
+
63
+ def apply_attributes(span, data)
64
+ data.each do |key, value|
65
+ coerced = attribute_value(value)
66
+ span.set_attribute("smith.#{key}", coerced) unless coerced.nil?
67
+ end
68
+ end
69
+
70
+ # OpenTelemetry attributes accept strings, integers, floats, and
71
+ # booleans; numeric values keep their type instead of arriving as
72
+ # strings, everything else (symbols included) becomes a string, nil
73
+ # drops.
74
+ def attribute_value(value)
75
+ case value
76
+ when String, Integer, Float, true, false then value
77
+ when nil then nil
78
+ else value.to_s
79
+ end
80
+ end
81
+
38
82
  def type_enabled?(type)
39
83
  config_key = CONFIG_MAP[type]
40
84
  return true unless config_key
data/lib/smith/trace.rb CHANGED
@@ -1,34 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attribution"
4
+
3
5
  module Smith
4
6
  module Trace
5
7
  SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
8
+ ADAPTER_MUTEX = Mutex.new
9
+ private_constant :ADAPTER_MUTEX
6
10
 
7
11
  def self.record(type:, data:, sensitivity: :low)
8
12
  adapter = resolve_adapter
9
13
  return unless adapter
10
14
 
11
- filtered = apply_content_policy(data, sensitivity)
15
+ filtered = apply_content_policy(attributed(data), sensitivity)
12
16
  filtered = filter_fields(type, filtered)
13
17
  adapter.record(type: type, data: filtered)
14
18
  rescue StandardError => e
15
19
  Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
16
20
  end
17
21
 
22
+ # Ambient attribution keys are identifiers, not content: they merge in
23
+ # under the caller's own keys (the caller wins on conflict) and then pass
24
+ # through the same content policy and field allowlist as everything else,
25
+ # so a host's configured trace_fields contract keeps holding.
26
+ def self.attributed(data)
27
+ return data unless Smith.config.trace_attribution
28
+
29
+ fields = Attribution.current_fields
30
+ return data if fields.empty?
31
+
32
+ fields.merge(data)
33
+ end
34
+
35
+ # Class-configured adapters memoize one instance under a mutex so
36
+ # concurrent first records (every fan-out branch emits) share a single
37
+ # adapter instead of racing separate instances and losing entries.
18
38
  def self.resolve_adapter
19
39
  configured = Smith.config.trace_adapter
20
40
  return nil unless configured
41
+ return configured unless configured.is_a?(Class)
21
42
 
22
- if configured.is_a?(Class)
43
+ ADAPTER_MUTEX.synchronize do
23
44
  @adapter_instances ||= {}
24
45
  @adapter_instances[configured] ||= configured.new
25
- else
26
- configured
27
46
  end
28
47
  end
29
48
 
30
49
  def self.reset!
31
- @adapter_instances = nil
50
+ ADAPTER_MUTEX.synchronize { @adapter_instances = nil }
32
51
  end
33
52
 
34
53
  def self.apply_content_policy(data, sensitivity)
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  EXECUTION_SEMANTICS_VERSION = "5"
6
6
  end