Instrumentation

Table of contents

  1. Basic Usage
    1. Tracing a Single Call
    2. Tracing a Block
    3. Custom Output
  2. Pass Modes
    1. Chain Tracing Examples
      1. Smart Chaining with Extracted Parameters
      2. Fallback Chains
  3. How It Works

Runtime tracing and debugging for operations and chains.

Basic Usage

Tracing a Single Call

result = MyOperation.explain(param: value)

Prints an execution tree to stdout and returns the result.

Tracing a Block

TypedOperation::Instrumentation.explaining do
  MyOperation.call(params)
  AnotherOperation.call(other_params)
end

Custom Output

Control where trace output is written and whether to use colors:

TypedOperation::Instrumentation.with_output($stderr, color: false) do
  MyOperation.explain(param: value)
end

Method signature:

  • Instrumentation.with_output(io, color: nil, &block)
    • io - Any IO object (e.g., $stdout, $stderr, StringIO.new)
    • color - Boolean to enable/disable ANSI colors (defaults to auto-detect based on TTY)

Pass Modes

When tracing chained operations, the trace output shows how values are passed between operations:

  • (**ctx) - Context splatted as keyword arguments
    • Appears with .then when the next operation accepts keyword parameters
    • Smart chaining automatically extracts matching parameters from context
  • (ctx) - Single value passed as positional argument
    • Appears with .then when the next operation accepts a single positional parameter
    • Also used with .then_passes to explicitly pass the full context
  • (transform) - Value transformed by a block
    • Appears with .transform method
  • (fallback) - Fallback operation executed after failure
    • Appears with .or_else method

Chain Tracing Examples

Smart Chaining with Extracted Parameters

When chaining operations that accept keyword arguments, the tracer shows which parameters were extracted from the context:

class CreateUser < TypedOperation::Base
  include TypedOperation::Explainable
  param :email, String
  param :name, String

  def perform
    {id: 1, email: email, name: name}
  end
end

class SendWelcome < TypedOperation::Base
  include TypedOperation::Explainable
  param :email, String
  param :name, String

  def perform
    "Welcome email sent to #{email}"
  end
end

chain = CreateUser.then(SendWelcome)
chain.explain(email: "user@example.com", name: "Alice")

# Output:
# CreateUser [2.1ms] ✓
#   →
# SendWelcome [1.3ms] ✓ (**ctx) ← [email, name]
# => "Welcome email sent to user@example.com"

Understanding the output:

  • (**ctx) - Indicates keyword argument splatting was used
  • ← [email, name] - Shows which parameters were extracted from the previous operation’s result
    • This helps you see which fields from the context are being passed to each operation
    • Only parameters that match the operation’s defined parameters are extracted

Fallback Chains

Fallback operations are shown with a special arrow:

class ApiCall < TypedOperation::Base
  include TypedOperation::Explainable
  param :should_fail, _Boolean

  def perform
    raise "API error" if should_fail
    "success"
  end
end

class FallbackHandler < TypedOperation::Base
  include TypedOperation::Explainable

  def perform
    "fallback response"
  end
end

chain = ApiCall.or_else(FallbackHandler)
chain.explain(should_fail: true)

# Output:
# ApiCall [0.5ms] ✗ (RuntimeError: API error)
#   ⤷
# ⤷ FallbackHandler [0.2ms] ✓ (fallback)
# => "fallback response"

The arrow indicates fallback execution, and (fallback) shows the pass mode.

How It Works

Instrumentation uses thread-local storage to track execution without modifying call signatures. Two mechanisms work together:

  1. Trace Stack - Maintains the call hierarchy as a stack of Trace objects
  2. Chain Context - Captures metadata about how operations are invoked (pass mode, extracted parameters, fallback status)

When .explain is called, a root trace is pushed (enabling tracing? = true). As chains execute, they record context via set_chain_context before calling each operation. The operation’s Traceable wrapper consumes this context via take_chain_context, then pushes/pops traces as execution progresses.

Thread-locals provide a side-channel for passing instrumentation data without polluting the call interface. Since Thread.current[] isolates data per-thread and chains execute synchronously, this approach is thread-safe. The tracing? guard ensures instrumentation is a no-op when not actively debugging.