dexkit 0.9.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.
@@ -264,7 +264,7 @@ info = Order::Place.explain(product: product, customer: customer, quantity: 2)
264
264
  # transaction: { enabled: true },
265
265
  # rescue_from: { "Stripe::CardError" => :card_declined },
266
266
  # callbacks: { before: 1, after: 2, around: 0 },
267
- # pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
267
+ # pipeline: [:trace, :result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
268
268
  # callable: true
269
269
  # }
270
270
  ```
@@ -481,8 +481,12 @@ Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref
481
481
  Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
482
482
 
483
483
  ```ruby
484
- create_table :operation_records do |t|
484
+ create_table :operation_records, id: :string do |t|
485
485
  t.string :name, null: false # operation class name
486
+ t.string :trace_id # shared trace / correlation ID
487
+ t.string :actor_type # root actor type
488
+ t.string :actor_id # root actor ID
489
+ t.jsonb :trace # full trace snapshot
486
490
  t.jsonb :params # serialized props (nil = not captured)
487
491
  t.jsonb :result # serialized return value
488
492
  t.string :status, null: false # pending/running/completed/error/failed
@@ -498,6 +502,8 @@ end
498
502
  add_index :operation_records, :name
499
503
  add_index :operation_records, :status
500
504
  add_index :operation_records, [:name, :status]
505
+ add_index :operation_records, :trace_id
506
+ add_index :operation_records, [:actor_type, :actor_id]
501
507
  ```
502
508
 
503
509
  Control per-operation:
@@ -518,12 +524,30 @@ Required attributes by feature:
518
524
  - Async record jobs: `params`
519
525
  - `once`: `once_key`, plus `once_key_expires_at` when `expires_in:` is used
520
526
 
527
+ Trace columns (`id`, `trace_id`, `actor_type`, `actor_id`, `trace`) are recommended for tracing. Dex persists them when present, omits them when missing.
528
+
521
529
  Untyped results are sanitized to JSON-safe values before persistence: Hash keys round-trip as strings, and objects fall back to `as_json`/`to_s` under `"_dex_value"`.
522
530
 
523
531
  Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
524
532
 
525
533
  When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
526
534
 
535
+ ## Execution tracing
536
+
537
+ Every operation call gets an `op_...` execution ID and joins a fiber-local trace shared across operations, handlers, and async jobs.
538
+
539
+ ```ruby
540
+ Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
541
+ Order::Place.call(product: product, customer: customer, quantity: 2)
542
+ end
543
+
544
+ Dex::Trace.trace_id # => "tr_..."
545
+ Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
546
+ Dex::Trace.to_s # => "user:42 > Order::Place(op_2nFg7K)"
547
+ ```
548
+
549
+ Tracing is always on – no opt-in needed. Async operations serialize and restore the trace automatically. When recording is enabled, `trace_id`, `actor_type`, `actor_id`, and `trace` are persisted alongside the usual record fields.
550
+
527
551
  ---
528
552
 
529
553
  ## Idempotency (once)
@@ -577,7 +601,7 @@ ChargeOrder.clear_once!("webhook-123") # by raw string key
577
601
 
578
602
  Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
579
603
 
580
- **Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
604
+ **Pipeline position:** trace → result → guard → **once** → lock → record → transaction → rescue → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
581
605
 
582
606
  **Requirements:**
583
607
 
data/guides/llm/QUERY.md CHANGED
@@ -10,12 +10,17 @@ All examples below build on this query unless noted otherwise:
10
10
 
11
11
  ```ruby
12
12
  class UserSearch < Dex::Query
13
+ description "Search and filter users"
14
+
13
15
  scope { User.all }
14
16
 
15
17
  prop? :name, String
16
18
  prop? :role, _Array(String)
17
19
  prop? :age_min, Integer
18
20
  prop? :status, String
21
+ prop? :tenant, String
22
+
23
+ context tenant: :current_tenant
19
24
 
20
25
  filter :name, :contains
21
26
  filter :role, :in
@@ -71,6 +76,51 @@ prop? :age_min, Integer # optional integer
71
76
 
72
77
  Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_params`, `param_key`.
73
78
 
79
+ ### Description
80
+
81
+ ```ruby
82
+ class UserSearch < Dex::Query
83
+ description "Search and filter users"
84
+ end
85
+ ```
86
+
87
+ ### Context
88
+
89
+ Same `context` DSL as Operation and Event. Auto-fills props from `Dex.with_context`:
90
+
91
+ ```ruby
92
+ class UserSearch < Dex::Query
93
+ prop? :tenant, String
94
+ context tenant: :current_tenant
95
+ end
96
+
97
+ # In a controller around_action:
98
+ Dex.with_context(current_tenant: current_tenant) do
99
+ UserSearch.call(name: "ali") # tenant auto-filled
100
+ end
101
+ ```
102
+
103
+ Identity shorthand: `context :locale` maps prop `:locale` to context key `:locale`.
104
+
105
+ Explicit values always win over ambient context. Inheritance merges parent + child mappings.
106
+
107
+ ---
108
+
109
+ ## Registry & Export
110
+
111
+ Queries extend `Registry` — same API as Operation, Event, and Form:
112
+
113
+ ```ruby
114
+ Dex::Query.registry # => Set of all named Query subclasses
115
+ UserSearch.description # => "Search and filter users"
116
+
117
+ UserSearch.to_h # => { name:, description:, props:, filters:, sorts:, context: }
118
+ UserSearch.to_json_schema # => JSON Schema (Draft 2020-12)
119
+
120
+ Dex::Query.export(format: :hash) # => sorted array of all query to_h
121
+ Dex::Query.export(format: :json_schema) # => sorted array of all query to_json_schema
122
+ ```
123
+
74
124
  ---
75
125
 
76
126
  ## Filters
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Shared context DSL extracted from ContextSetup.
5
+ #
6
+ # Provides the `context` class method, `context_mappings` inheritance,
7
+ # and `_context_own` storage. Includers must implement:
8
+ # _context_prop_declared?(name) → true/false
9
+ # and may override:
10
+ # _context_field_label → "prop" | "field" (used in error messages)
11
+ module ContextDSL
12
+ def context(*names, **mappings)
13
+ names.each do |name|
14
+ unless name.is_a?(Symbol)
15
+ raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
+ end
17
+ mappings[name] = name
18
+ end
19
+
20
+ raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
+
22
+ label = _context_field_label
23
+ mappings.each do |prop_name, context_key|
24
+ unless _context_prop_declared?(prop_name)
25
+ raise ArgumentError,
26
+ "context references undeclared #{label} :#{prop_name}. Declare the #{label} before calling context."
27
+ end
28
+ unless context_key.is_a?(Symbol)
29
+ raise ArgumentError,
30
+ "context key must be a Symbol, got: #{context_key.inspect} for #{label} :#{prop_name}"
31
+ end
32
+ end
33
+
34
+ _context_own.merge!(mappings)
35
+ end
36
+
37
+ def context_mappings
38
+ parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
39
+ parent.merge(_context_own)
40
+ end
41
+
42
+ private
43
+
44
+ def _context_own
45
+ @_context_own_mappings ||= {}
46
+ end
47
+
48
+ def _context_prop_declared?(_name)
49
+ raise NotImplementedError, "#{self} must implement _context_prop_declared?"
50
+ end
51
+
52
+ def _context_field_label
53
+ "prop"
54
+ end
55
+ end
56
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- # Shared context DSL for Operation and Event.
4
+ # Context DSL for Operation and Event (Literal::Properties-backed).
5
5
  #
6
6
  # Maps declared props to ambient context keys so they can be auto-filled
7
7
  # from Dex.context when not passed explicitly as kwargs.
@@ -9,34 +9,7 @@ module Dex
9
9
  extend Dex::Concern
10
10
 
11
11
  module ClassMethods
12
- def context(*names, **mappings)
13
- names.each do |name|
14
- unless name.is_a?(Symbol)
15
- raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
- end
17
- mappings[name] = name
18
- end
19
-
20
- raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
-
22
- mappings.each do |prop_name, context_key|
23
- unless _context_prop_declared?(prop_name)
24
- raise ArgumentError,
25
- "context references undeclared prop :#{prop_name}. Declare the prop before calling context."
26
- end
27
- unless context_key.is_a?(Symbol)
28
- raise ArgumentError,
29
- "context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
30
- end
31
- end
32
-
33
- _context_own.merge!(mappings)
34
- end
35
-
36
- def context_mappings
37
- parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
38
- parent.merge(_context_own)
39
- end
12
+ include ContextDSL
40
13
 
41
14
  def new(**kwargs)
42
15
  mappings = context_mappings
@@ -52,10 +25,6 @@ module Dex
52
25
 
53
26
  private
54
27
 
55
- def _context_own
56
- @_context_own_mappings ||= {}
57
- end
58
-
59
28
  def _context_prop_declared?(name)
60
29
  respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
61
30
  end
data/lib/dex/event/bus.rb CHANGED
@@ -44,19 +44,18 @@ module Dex
44
44
  def publish(event, sync:)
45
45
  return if Suppression.suppressed?(event.class)
46
46
 
47
- persist(event)
47
+ trace_data = trace_data_for(event)
48
+ persist(event, trace_data)
48
49
  handlers = subscribers_for(event.class)
49
50
  return if handlers.empty?
50
51
 
51
- event_frame = event.trace_frame
52
-
53
52
  handlers.each do |handler_class|
54
53
  if sync
55
- Trace.restore(event_frame) do
54
+ Dex::Trace.restore(trace_data) do
56
55
  handler_class._event_handle(event)
57
56
  end
58
57
  else
59
- enqueue(handler_class, event, event_frame)
58
+ enqueue(handler_class, event, trace_data)
60
59
  end
61
60
  end
62
61
  end
@@ -67,15 +66,23 @@ module Dex
67
66
 
68
67
  private
69
68
 
70
- def persist(event)
69
+ def persist(event, trace_data)
71
70
  store = Dex.configuration.event_store
72
71
  return unless store
73
72
 
74
- store.create!(
73
+ actor = actor_from_trace(trace_data[:frames])
74
+ attrs = safe_store_attributes(store, {
75
+ id: event.id,
76
+ trace_id: event.trace_id,
77
+ actor_type: actor&.dig(:actor_type),
78
+ actor_id: actor&.dig(:id),
79
+ trace: trace_data[:frames],
75
80
  event_type: event.class.name,
76
81
  payload: event._props_as_json,
77
82
  metadata: event.metadata.as_json
78
- )
83
+ })
84
+
85
+ store.create!(**attrs)
79
86
  rescue => e
80
87
  Event._warn("Failed to persist event: #{e.message}")
81
88
  end
@@ -94,6 +101,69 @@ module Dex
94
101
  )
95
102
  end
96
103
 
104
+ def trace_data_for(event)
105
+ ambient = Dex::Trace.dump
106
+ frames = if ambient && trace_matches_event?(ambient, event)
107
+ trace_frames(ambient)
108
+ elsif ambient
109
+ actor_frames(trace_frames(ambient))
110
+ else
111
+ []
112
+ end
113
+
114
+ {
115
+ trace_id: event.trace_id,
116
+ frames: frames,
117
+ event_context: event_context_for(event)
118
+ }
119
+ end
120
+
121
+ def actor_from_trace(frames)
122
+ Array(frames).find do |frame|
123
+ frame_type = frame[:type] || frame["type"]
124
+ frame_type && frame_type.to_sym == :actor
125
+ end
126
+ end
127
+
128
+ def actor_frames(frames)
129
+ Array(frames).select do |frame|
130
+ frame_type = frame[:type] || frame["type"]
131
+ frame_type && frame_type.to_sym == :actor
132
+ end
133
+ end
134
+
135
+ def trace_frames(trace_data)
136
+ Array(trace_data[:frames] || trace_data["frames"])
137
+ end
138
+
139
+ def trace_matches_event?(trace_data, event)
140
+ trace_id = trace_data[:trace_id] || trace_data["trace_id"]
141
+ trace_id.to_s == event.trace_id.to_s
142
+ end
143
+
144
+ def event_context_for(event)
145
+ {
146
+ id: event.id,
147
+ trace_id: event.trace_id,
148
+ event_class: event.class.name,
149
+ event_ancestry: event.event_ancestry
150
+ }
151
+ end
152
+
153
+ def safe_store_attributes(store, attributes)
154
+ if store.respond_to?(:column_names)
155
+ allowed = store.column_names.to_set
156
+ attributes.select { |key, _| allowed.include?(key.to_s) }
157
+ elsif store.respond_to?(:fields)
158
+ attributes.select do |key, _|
159
+ field_name = key.to_s
160
+ store.fields.key?(field_name) || (field_name == "id" && store.fields.key?("_id"))
161
+ end
162
+ else
163
+ attributes
164
+ end
165
+ end
166
+
97
167
  def ensure_active_job_loaded!
98
168
  return if defined?(ActiveJob::Base)
99
169
 
@@ -69,9 +69,26 @@ module Dex
69
69
  end
70
70
 
71
71
  def self._event_handle(event)
72
+ execution_id = Dex::Id.generate("hd_")
73
+ auto_started = Dex::Trace.ensure_started!(trace_id: event.trace_id)
74
+ pushed = false
75
+ Dex::Trace.push(
76
+ type: :handler,
77
+ id: execution_id,
78
+ class: name,
79
+ event_class: event.class.name,
80
+ event_id: event.id,
81
+ event_ancestry: event.metadata.event_ancestry
82
+ )
83
+ pushed = true
84
+
72
85
  instance = new
73
86
  instance.instance_variable_set(:@event, event)
87
+ instance.instance_variable_set(:@_dex_execution_id, execution_id)
74
88
  instance.send(:call)
89
+ ensure
90
+ Dex::Trace.pop if pushed
91
+ Dex::Trace.stop! if auto_started
75
92
  end
76
93
 
77
94
  def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
@@ -96,6 +113,7 @@ module Dex
96
113
  timestamp: Time.parse(metadata_hash["timestamp"]),
97
114
  trace_id: metadata_hash["trace_id"],
98
115
  caused_by_id: metadata_hash["caused_by_id"],
116
+ event_ancestry: metadata_hash["event_ancestry"] || [],
99
117
  context: metadata_hash["context"]
100
118
  )
101
119
  instance.instance_variable_set(:@metadata, metadata)
@@ -1,25 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Dex
6
4
  class Event
7
5
  class Metadata
8
- attr_reader :id, :timestamp, :trace_id, :caused_by_id, :context
6
+ attr_reader :id, :timestamp, :trace_id, :caused_by_id, :event_ancestry, :context
9
7
 
10
- def initialize(id:, timestamp:, trace_id:, caused_by_id:, context:)
8
+ def initialize(id:, timestamp:, trace_id:, caused_by_id:, event_ancestry:, context:)
11
9
  @id = id
12
10
  @timestamp = timestamp
13
11
  @trace_id = trace_id
14
12
  @caused_by_id = caused_by_id
13
+ @event_ancestry = event_ancestry
15
14
  @context = context
16
15
  freeze
17
16
  end
18
17
 
19
- def self.build(caused_by_id: nil)
20
- id = SecureRandom.uuid
21
- trace_id = Trace.current_trace_id || id
22
- caused = caused_by_id || Trace.current_event_id
18
+ def self.build
19
+ id = Dex::Id.generate("ev_")
20
+ trace_id = Dex::Trace.trace_id || Dex::Id.generate("tr_")
21
+ current_event = Dex::Trace.current_event_context
22
+ caused = current_event&.dig(:id)
23
+ ancestry = if current_event
24
+ Array(current_event[:event_ancestry]) + [caused].compact
25
+ else
26
+ []
27
+ end
23
28
 
24
29
  ctx = if Dex.configuration.event_context
25
30
  begin
@@ -35,6 +40,7 @@ module Dex
35
40
  timestamp: Time.now.utc,
36
41
  trace_id: trace_id,
37
42
  caused_by_id: caused,
43
+ event_ancestry: ancestry,
38
44
  context: ctx
39
45
  )
40
46
  end
@@ -43,7 +49,8 @@ module Dex
43
49
  h = {
44
50
  "id" => @id,
45
51
  "timestamp" => @timestamp.iso8601(6),
46
- "trace_id" => @trace_id
52
+ "trace_id" => @trace_id,
53
+ "event_ancestry" => @event_ancestry
47
54
  }
48
55
  h["caused_by_id"] = @caused_by_id if @caused_by_id
49
56
  h["context"] = @context if @context
@@ -13,7 +13,7 @@ module Dex
13
13
  handler = Object.const_get(handler_class)
14
14
  retry_config = handler._event_handler_retry_config
15
15
 
16
- Dex::Event::Trace.restore(trace) do
16
+ Dex::Trace.restore(trace) do
17
17
  handler._event_handle_from_payload(event_class, payload, metadata)
18
18
  end
19
19
  rescue => _e
@@ -65,7 +65,7 @@ module Dex
65
65
  def setup
66
66
  super
67
67
  EventTestWrapper.clear_published!
68
- Dex::Event::Trace.clear!
68
+ Dex::Trace.clear!
69
69
  Dex::Event::Suppression.clear!
70
70
  end
71
71
 
@@ -3,52 +3,39 @@
3
3
  module Dex
4
4
  class Event
5
5
  module Trace
6
- STACK_KEY = :_dex_event_trace_stack
7
-
8
6
  class << self
9
- include ExecutionState
10
-
11
7
  def with_event(event, &block)
12
- stack = _stack
13
- stack.push(event.trace_frame)
14
- yield
15
- ensure
16
- stack.pop
8
+ Dex::Trace.with_event_context(event, &block)
17
9
  end
18
10
 
19
11
  def current_event_id
20
- _stack.last&.dig(:id)
12
+ Dex::Trace.current_event_id
21
13
  end
22
14
 
23
15
  def current_trace_id
24
- _stack.last&.dig(:trace_id)
16
+ Dex::Trace.trace_id
25
17
  end
26
18
 
27
19
  def dump
28
- frame = _stack.last
29
- return nil unless frame
30
-
31
- { id: frame[:id], trace_id: frame[:trace_id] }
20
+ Dex::Trace.dump
32
21
  end
33
22
 
34
23
  def restore(data, &block)
35
24
  return yield unless data
36
25
 
37
- stack = _stack
38
- stack.push(data)
39
- yield
40
- ensure
41
- stack.pop if data
26
+ if data.is_a?(Hash) && (data.key?(:frames) || data.key?("frames"))
27
+ Dex::Trace.restore(data, &block)
28
+ else
29
+ Dex::Trace.restore_event_context(
30
+ event_id: data[:id] || data["id"],
31
+ trace_id: data[:trace_id] || data["trace_id"],
32
+ &block
33
+ )
34
+ end
42
35
  end
43
36
 
44
37
  def clear!
45
- _execution_state[STACK_KEY] = []
46
- end
47
-
48
- private
49
-
50
- def _stack
51
- _execution_state[STACK_KEY] ||= []
38
+ Dex::Trace.clear!
52
39
  end
53
40
  end
54
41
  end
data/lib/dex/event.rb CHANGED
@@ -9,7 +9,7 @@ require_relative "event/suppression"
9
9
  module Dex
10
10
  class Event
11
11
  RESERVED_PROP_NAMES = %i[
12
- id timestamp trace_id caused_by_id caused_by
12
+ id timestamp trace_id caused_by_id caused_by event_ancestry
13
13
  context publish metadata sync
14
14
  ].to_set.freeze
15
15
 
@@ -67,8 +67,8 @@ module Dex
67
67
  def timestamp = metadata.timestamp
68
68
  def trace_id = metadata.trace_id
69
69
  def caused_by_id = metadata.caused_by_id
70
+ def event_ancestry = metadata.event_ancestry
70
71
  def context = metadata.context
71
- def trace_frame = { id: id, trace_id: trace_id }
72
72
 
73
73
  # Publishing
74
74
  def publish(sync: false)
@@ -85,11 +85,6 @@ module Dex
85
85
  end
86
86
  end
87
87
 
88
- # Tracing
89
- def trace(&block)
90
- Trace.with_event(self, &block)
91
- end
92
-
93
88
  # Suppression
94
89
  def self.suppress(*classes, &block)
95
90
  Suppression.suppress(*classes, &block)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ # Context DSL for Form (ActiveModel::Attributes-backed).
6
+ #
7
+ # Same DSL as Operation/Event context, but checks attribute_names
8
+ # instead of literal_properties. Injection happens in Form#initialize.
9
+ module Context
10
+ extend Dex::Concern
11
+
12
+ module ClassMethods
13
+ include ContextDSL
14
+
15
+ private
16
+
17
+ def _context_prop_declared?(name)
18
+ attribute_names.include?(name.to_s)
19
+ end
20
+
21
+ def _context_field_label
22
+ "field"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end