From 71dd325ac920730f17f3209ff209bf4035a59635 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 23 Aug 2025 12:30:21 +1200 Subject: [PATCH 1/3] Add `Traces.current_context` and `Traces.with_context`. --- guides/context-propagation/readme.md | 212 +++++++++++ guides/links.yaml | 6 +- lib/traces.rb | 1 + lib/traces/backend.rb | 81 +++++ lib/traces/config.rb | 14 +- lib/traces/context.rb | 66 +++- readme.md | 2 + releases.md | 17 + test/traces/context.rb | 510 ++++++++++++++++++++++++++- 9 files changed, 892 insertions(+), 17 deletions(-) create mode 100644 guides/context-propagation/readme.md diff --git a/guides/context-propagation/readme.md b/guides/context-propagation/readme.md new file mode 100644 index 0000000..256cb8a --- /dev/null +++ b/guides/context-propagation/readme.md @@ -0,0 +1,212 @@ +# Context Propagation + +This guide explains how to propagate trace context between different execution contexts within your application using `Traces.current_context` and `Traces.with_context`. + +## Overview + +The `traces` library provides two complementary approaches for managing trace context: + +- **Local context propagation** (`Traces.current_context` / `Traces.with_context`): For passing context between execution contexts within the same process (threads, fibers, async tasks). +- **Distributed context propagation** (`Traces.inject` / `Traces.extract`): For transmitting context across process and service boundaries via serialization (HTTP headers, message metadata, etc.). + +There is a legacy interface `Traces.trace_context` and `Traces.trace_context=` but you should prefer to use the new methods outlined above. + +## Local Context Propagation + +Local context propagation involves passing trace context between different execution contexts within the same process. This is essential for maintaining trace continuity when code execution moves between threads, fibers, async tasks, or other concurrent execution contexts. Unlike distributed propagation which requires serialization over network boundaries, local propagation uses Context objects directly. + +### Capturing the Current Context + +Use `Traces.current_context` to capture the current trace context as a Context object: + +~~~ ruby +current_context = Traces.current_context +# Returns a Traces::Context object or nil if no active trace +~~~ + +### Using the Context + +Use `Traces.with_context(context)` to execute code within a specific trace context: + +~~~ ruby +# With block (automatic restoration): +Traces.with_context(context) do + # Code runs with the specified context. +end + +# Without block (permanent switch): +Traces.with_context(context) +# Context remains active. +~~~ + +### Use Cases + +#### Thread-Safe Context Propagation + +When spawning background threads, you often want them to inherit the current trace context: + +~~~ ruby +require 'traces' + +# Main thread has active tracing +Traces.trace("main_operation") do + # Capture current context before spawning thread: + current_context = Traces.current_context + + # Spawn background thread: + Thread.new do + # Restore context in the new thread: + Traces.with_context(current_context) do + # This thread now has the same trace context as main thread: + Traces.trace("background_work") do + perform_heavy_computation + end + end + end.join +end +~~~ + +#### Fiber-Based Async Operations + +For fiber-based concurrency (like in async frameworks), context propagation ensures trace continuity: + +~~~ ruby +require 'traces' + +Traces.trace("main_operation") do + current_context = Traces.current_context + + # Create fiber for async work: + fiber = Fiber.new do + Traces.with_context(current_context) do + # Fiber inherits the trace context: + Traces.trace("fiber_work") do + perform_async_operation + end + end + end + + fiber.resume +end +~~~ + +### Context Propagation vs. New Spans + +Remember that context propagation maintains the same trace, while `trace()` creates new spans: + +~~~ ruby +Traces.trace("parent") do + context = Traces.current_context + + Thread.new do + # This maintains the same trace context: + Traces.with_context(context) do + # This creates a NEW span within the same trace: + Traces.trace("child") do + # Child span, same trace as parent + end + end + end +end +~~~ + +## Distributed Context Propagation + +Distributed context propagation involves transmitting trace context across process and service boundaries. Unlike local propagation which works within a single process, distributed propagation requires serializing context data and transmitting it over network protocols. + +### Injecting Context into Headers + +Use `Traces.inject(headers, context = nil)` to add W3C Trace Context headers to a headers hash for transmission over network boundaries: + +~~~ ruby +require 'traces' + +# Capture current context: +context = Traces.current_context +headers = {'Content-Type' => 'application/json'} + +# Inject trace headers: +Traces.inject(headers, context) +# headers now contains: {'Content-Type' => '...', 'traceparent' => '00-...'} + +# Or use current context by default: +Traces.inject(headers) # Uses current trace context +~~~ + +### Extracting Context from Headers + +Use `Traces.extract(headers)` to extract trace context from W3C headers received over the network: + +~~~ ruby +# Receive headers from incoming request: +incoming_headers = request.headers + +# Extract context: +context = Traces.extract(incoming_headers) +# Returns a Traces::Context object or nil if no valid context + +# Use the extracted context: +if context + Traces.with_context(context) do + # Process request with distributed trace context + end +end +~~~ + +### Use Cases + +#### Outgoing HTTP Requests + +~~~ ruby +require 'traces' + +class ApiClient + def make_request(endpoint, data) + Traces.trace("api_request", attributes: {endpoint: endpoint}) do + headers = { + 'content-type' => 'application/json' + } + + # Add trace context to outgoing request: + Traces.inject(headers) + + http_client.post(endpoint, + body: data.to_json, + headers: headers + ) + end + end +end +~~~ + +#### Incoming HTTP Requests + +~~~ ruby +require 'traces' + +class WebController + def handle_request(request) + # Extract trace context from incoming headers: + context = Traces.extract(request.headers) + + # Process request with inherited context: + if context + Traces.with_context(context) do + Traces.trace("web_request", attributes: { + path: request.path, + method: request.method + }) do + process_business_logic + end + end + else + Traces.trace("web_request", attributes: { + path: request.path, + method: request.method + }) do + process_business_logic + end + end + end +end +~~~ diff --git a/guides/links.yaml b/guides/links.yaml index 91f2fef..9250b64 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,6 +1,8 @@ getting-started: order: 1 -testing: +context-propagation: order: 2 -capture: +testing: order: 3 +capture: + order: 4 diff --git a/lib/traces.rb b/lib/traces.rb index ada5a76..017e553 100644 --- a/lib/traces.rb +++ b/lib/traces.rb @@ -5,6 +5,7 @@ require_relative "traces/version" require_relative "traces/provider" +require_relative "traces/context" # @namespace module Traces diff --git a/lib/traces/backend.rb b/lib/traces/backend.rb index b78f920..9b7c460 100644 --- a/lib/traces/backend.rb +++ b/lib/traces/backend.rb @@ -4,23 +4,104 @@ # Copyright, 2021-2025, by Samuel Williams. require_relative "config" +require_relative "context" module Traces # The backend implementation is responsible for recording and reporting traces. module Backend end + # Capture the current trace context for remote propagation. + # # This is a default implementation, which can be replaced by the backend. + # + # You should prefer to use the new `Traces.current_context` family of methods. + # # @returns [Object] The current trace context. def self.trace_context nil end + # Whether there is an active trace context. + # # This is a default implementation, which can be replaced by the backend. + # # @returns [Boolean] Whether there is an active trace. def self.active? !!self.trace_context end + # Capture the current trace context for local propagation between execution contexts. + # + # This method returns the current trace context that can be safely passed between threads, fibers, or other execution contexts within the same process. + # + # The returned object is opaque, in other words, you should not make assumptions about its structure. + # + # This is a default implementation, which can be replaced by the backend. + # + # @returns [Context | Nil] The current trace context, or nil if no active trace. + def self.current_context + trace_context + end + + # Execute a block within a specific trace context for local execution. + # + # This method is designed for propagating trace context between execution contexts within the same process (threads, fibers, etc.). It temporarily switches to the specified trace context for the duration of the block execution, then restores the previous context. + # + # When called without a block, permanently switches to the specified context. This enables manual context management for scenarios where automatic restoration isn't desired. + # + # This is a default implementation, which can be replaced by the backend. + # + # @parameter context [Context] A trace context obtained from `Traces.current_context`. + # @yields {...} If a block is given, the block is executed within the specified trace context. + def self.with_context(context) + if block_given? + # This implementation is not ideal but the best we can do with the current interface. + previous_context = self.trace_context + begin + self.trace_context = context + yield + ensure + self.trace_context = previous_context + end + else + self.trace_context = context + end + end + + # Inject trace context into a headers hash for distributed propagation. + # + # This method adds W3C Trace Context headers (traceparent, tracestate) and W3C Baggage headers to the provided headers hash, enabling distributed tracing across service boundaries. The headers hash is mutated in place. + # + # This is a default implementation, which can be replaced by the backend. + # + # @parameter headers [Hash] The headers object to mutate with trace context headers. + # @parameter context [Context] A trace context, or nil to use current context. + # @returns [Hash | Nil] The headers hash, or nil if no context is available. + def self.inject(headers = nil, context = nil) + context ||= self.trace_context + + if context + headers ||= Hash.new + context.inject(headers) + else + headers = nil + end + + return headers + end + + # Extract trace context from headers for distributed propagation. + # + # The returned object is opaque, in other words, you should not make assumptions about its structure. + # + # This is a default implementation, which can be replaced by the backend. + # + # @parameter headers [Hash] The headers object containing trace context. + # @returns [Context, nil] The extracted trace context, or nil if no valid context found. + def self.extract(headers) + Context.extract(headers) + end + Config::DEFAULT.require_backend end diff --git a/lib/traces/config.rb b/lib/traces/config.rb index 90797f1..7cc3ce3 100644 --- a/lib/traces/config.rb +++ b/lib/traces/config.rb @@ -13,7 +13,7 @@ class Config # @returns [Config] The loaded configuration. def self.load(path) config = self.new - + if File.exist?(path) config.instance_eval(File.read(path), path) end @@ -35,12 +35,12 @@ def prepare def require_backend(env = ENV) if backend = env["TRACES_BACKEND"] begin - if require(backend) - # We ensure that the interface methods replace any existing methods by prepending the module: - Traces.singleton_class.prepend(Backend::Interface) - - return true - end + require(backend) + + # We ensure that the interface methods replace any existing methods by prepending the module: + Traces.singleton_class.prepend(Backend::Interface) + + return true rescue LoadError => error warn "Unable to load traces backend: #{backend.inspect}!" end diff --git a/lib/traces/context.rb b/lib/traces/context.rb index 53872e1..2320b38 100644 --- a/lib/traces/context.rb +++ b/lib/traces/context.rb @@ -11,10 +11,10 @@ class Context # Parse a string representation of a distributed trace. # @parameter parent [String] The parent trace context. # @parameter state [Array(String)] Any attached trace state. - def self.parse(parent, state = nil, **options) + def self.parse(parent, state = nil, baggage = nil, **options) version, trace_id, parent_id, flags = parent.split("-") - if version == "00" + if version == "00" && trace_id && parent_id && flags flags = Integer(flags, 16) if state.is_a?(String) @@ -25,11 +25,19 @@ def self.parse(parent, state = nil, **options) state = state.map{|item| item.split("=")}.to_h end - self.new(trace_id, parent_id, flags, state, **options) + if baggage.is_a?(String) + baggage = baggage.split(",") + end + + if baggage + baggage = baggage.map{|item| item.split("=")}.to_h + end + + self.new(trace_id, parent_id, flags, state, baggage, **options) end end - # Create a local trace context which is likley to be globally unique. + # Create a local trace context which is likely to be globally unique. # @parameter flags [Integer] Any trace context flags. def self.local(flags = 0, **options) self.new(SecureRandom.hex(16), SecureRandom.hex(8), flags, **options) @@ -53,17 +61,18 @@ def self.nested(parent, flags = 0) # @parameter flags [Integer] An 8-bit field that controls tracing flags such as sampling, trace level, etc. # @parameter state [Hash] Additional vendor-specific trace identification information. # @parameter remote [Boolean] Whether this context was created from a distributed trace header. - def initialize(trace_id, parent_id, flags, state = nil, remote: false) + def initialize(trace_id, parent_id, flags, state = nil, baggage = nil, remote: false) @trace_id = trace_id @parent_id = parent_id @flags = flags @state = state + @baggage = baggage @remote = remote end # Create a new nested trace context in which spans can be recorded. def nested(flags = @flags) - Context.new(@trace_id, SecureRandom.hex(8), flags, @state, remote: @remote) + Context.new(@trace_id, SecureRandom.hex(8), flags, @state, @baggage, remote: @remote) end # The ID of the whole trace forest and is used to uniquely identify a distributed trace through a system. It is represented as a 16-byte array, for example, 4bf92f3577b34da6a3ce929d0e0e4736. All bytes as zero (00000000000000000000000000000000) is considered an invalid value. @@ -75,9 +84,12 @@ def nested(flags = @flags) # An 8-bit field that controls tracing flags such as sampling, trace level, etc. These flags are recommendations given by the caller rather than strict rules. attr :flags - # Provides additional vendor-specific trace identification information across different distributed tracing systems. Conveys information about the operation's position in multiple distributed tracing graphs. + # Provides additional vendor-specific trace identification information across different distributed tracing systems. attr :state + # Provides additional application-specific trace identification information across different distributed tracing systems. + attr :baggage + # Denotes that the caller may have recorded trace data. When unset, the caller did not record trace data out-of-band. def sampled? (@flags & SAMPLED) != 0 @@ -100,6 +112,7 @@ def as_json parent_id: @parent_id, flags: @flags, state: @state, + baggage: @baggage, remote: @remote } end @@ -108,5 +121,44 @@ def as_json def to_json(...) as_json.to_json(...) end + + def inject(headers) + headers["traceparent"] = self.to_s + + if @state and !@state.empty? + headers["tracestate"] = self.state.map{|key, value| "#{key}=#{value}"}.join(",") + end + + if @baggage and !@baggage.empty? + headers["baggage"] = self.baggage.map{|key, value| "#{key}=#{value}"}.join(",") + end + + return headers + end + + # Extract the trace context from the headers. + # + # The `"traceparent"` header is a string representation of the trace context. If it is an Array, the first element is used, otherwise it is used as is. + # The `"tracestate"` header is a string representation of the trace state. If it is a String, it is split on commas before being processed. + # + # @parameter headers [Hash] The headers hash containing trace context. + # @returns [Context | Nil] The extracted trace context, or nil if no valid context found. + # @raises [ArgumentError] If headers is not a Hash or contains malformed trace data. + def self.extract(headers) + if traceparent = headers["traceparent"] + if traceparent.is_a?(Array) + traceparent = traceparent.first + end + + if traceparent.empty? + return nil + end + + tracestate = headers["tracestate"] + baggage = headers["baggage"] + + return self.parse(traceparent, tracestate, baggage, remote: true) + end + end end end diff --git a/readme.md b/readme.md index 5d542f5..8f444e9 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,8 @@ Please see the [project documentation](https://socketry.github.io/traces/) for m - [Getting Started](https://socketry.github.io/traces/guides/getting-started/index) - This guide explains how to use `traces` for tracing code execution. + - [Context Propagation](https://socketry.github.io/traces/guides/context-propagation/index) - This guide explains how to propagate trace context between execution contexts and across service boundaries. + - [Testing](https://socketry.github.io/traces/guides/testing/index) - This guide explains how to test traces in your code. - [Capture](https://socketry.github.io/traces/guides/capture/index) - This guide explains how to use `traces` for exporting traces from your application. This can be used to document all possible traces. diff --git a/releases.md b/releases.md index 77c9157..55ec49d 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,22 @@ # Releases +## Unreleased + +- **W3C Baggage Support** - Full support for W3C Baggage specification for application-specific context propagation. + +### New Context Propagation Interfaces + +`Traces#trace_context` and `Traces.trace_context` are insufficient for efficient inter-process tracing when using OpenTelemetry. That is because OpenTelemetry has it's own "Context" concept with arbitrary key-value storage (of which the current span is one such key/value pair). Unfortunately, OpenTelemetry requires those values to be propagated "inter-process" while ignores them for "intra-process" tracing. + +Therefore, in order to propagate this context, we introduce 4 new methods: + +- `Traces.current_context` - Capture the current trace context for local propagation between execution contexts (threads, fibers). +- `Traces.with_context(context)` - Execute code within a specific trace context, with automatic restoration when used with blocks. +- `Traces.inject(headers = nil, context = nil)` - Inject W3C Trace Context headers into a headers hash for distributed propagation. +- `Traces.extract(headers)` - Extract trace context from W3C Trace Context headers. + +The default implementation is built on top of `Traces.trace_context`, however these methods can be replaced by the backend. In that case, the `context` object is opaque, in other words it is library-specific, and you should not assume it is an instance of `Traces::Context`. + ## v0.17.0 - Remove support for `resource:` keyword argument with no direct replacement – use an attribute instead. diff --git a/test/traces/context.rb b/test/traces/context.rb index 7d656bd..655ce78 100644 --- a/test/traces/context.rb +++ b/test/traces/context.rb @@ -3,6 +3,11 @@ # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. +unless ENV["TRACES_BACKEND"] + abort "No backend specified, tests will fail!" +end + +require "traces" require "traces/context" require "json" @@ -75,7 +80,8 @@ with ".parse" do let(:trace_state) {nil} - let(:context) {Traces::Context.parse(trace_parent, trace_state)} + let(:baggage) {nil} + let(:context) {Traces::Context.parse(trace_parent, trace_state, baggage)} it "can extract trace context from string" do expect(context.trace_id).to be == trace_id @@ -90,5 +96,507 @@ expect(context.state).to be == {"foo" => "bar"} end end + + with "baggage", baggage: "user_id=123,session=abc" do + it "can extract baggage from string" do + expect(context.baggage).to be == {"user_id" => "123", "session" => "abc"} + end + end + + with "baggage array", baggage: ["user_id=123", "session=abc"] do + it "can extract baggage from array" do + expect(context.baggage).to be == {"user_id" => "123", "session" => "abc"} + end + end + end + + with "baggage functionality" do + let(:trace_id) {"496e95c5964f7cb924fc820a469a9f74"} + let(:parent_id) {"ae9b1d95d29fe974"} + let(:baggage) {{"user_id" => "123", "session" => "abc", "team" => "backend"}} + + it "can initialize with baggage" do + context = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + expect(context.baggage).to be == baggage + end + + it "can initialize without baggage" do + context = Traces::Context.new(trace_id, parent_id, 0) + expect(context.baggage).to be == nil + end + + it "propagates baggage to nested contexts" do + parent = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + child = parent.nested + + expect(child.baggage).to be == baggage + expect(child.trace_id).to be == trace_id + end + + it "includes baggage in JSON representation" do + context = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + json = context.as_json + + expect(json[:baggage]).to be == baggage + end + + with "#inject" do + it "injects baggage into headers" do + context = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + headers = {} + + context.inject(headers) + + expect(headers["baggage"]).not.to be == nil + expect(headers["baggage"]).to be =~ /user_id=123/ + expect(headers["baggage"]).to be =~ /session=abc/ + expect(headers["baggage"]).to be =~ /team=backend/ + end + + it "does not inject empty baggage" do + context = Traces::Context.new(trace_id, parent_id, 0, nil, {}) + headers = {} + + context.inject(headers) + + expect(headers["baggage"]).to be == nil + end + + it "does not inject nil baggage" do + context = Traces::Context.new(trace_id, parent_id, 0, nil, nil) + headers = {} + + context.inject(headers) + + expect(headers["baggage"]).to be == nil + end + end + + with ".extract" do + it "extracts baggage from headers" do + headers = { + "traceparent" => "00-#{trace_id}-#{parent_id}-0", + "baggage" => "user_id=123,session=abc,team=backend" + } + + context = Traces::Context.extract(headers) + + expect(context.baggage).to be == baggage + expect(context.remote?).to be == true + end + + it "extracts empty baggage gracefully" do + headers = { + "traceparent" => "00-#{trace_id}-#{parent_id}-0", + "baggage" => "" + } + + context = Traces::Context.extract(headers) + + expect(context.baggage).to be == {} + end + + it "works without baggage header" do + headers = { + "traceparent" => "00-#{trace_id}-#{parent_id}-0" + } + + context = Traces::Context.extract(headers) + + expect(context.baggage).to be == nil + end + + it "handles baggage with special characters" do + headers = { + "traceparent" => "00-#{trace_id}-#{parent_id}-0", + "baggage" => "key%20with%20space=value%20with%20space,special=test%3Dvalue" + } + + context = Traces::Context.extract(headers) + + expect(context.baggage).to be == { + "key%20with%20space" => "value%20with%20space", + "special" => "test%3Dvalue" + } + end + end + + with "round trip inject/extract" do + it "preserves baggage through inject and extract" do + original_context = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + headers = {} + + # Inject: + original_context.inject(headers) + + # Extract: + extracted_context = Traces::Context.extract(headers) + + expect(extracted_context.baggage).to be == original_context.baggage + expect(extracted_context.trace_id).to be == original_context.trace_id + expect(extracted_context.parent_id).to be == original_context.parent_id + end + + it "preserves both state and baggage together" do + state = {"vendor" => "custom", "sampling" => "1"} + original_context = Traces::Context.new(trace_id, parent_id, 1, state, baggage) + headers = {} + + # Inject: + original_context.inject(headers) + + # Extract: + extracted_context = Traces::Context.extract(headers) + + expect(extracted_context.state).to be == original_context.state + expect(extracted_context.baggage).to be == original_context.baggage + expect(extracted_context.flags).to be == original_context.flags + end + end + + with "integration with Traces.inject/extract" do + it "preserves baggage through Traces inject/extract methods" do + Traces.trace("test") do + # Create context with baggage + context_with_baggage = Traces::Context.new(trace_id, parent_id, 0, nil, baggage) + headers = {} + + # Use the backend inject method + Traces.inject(headers, context_with_baggage) + + # Should have baggage header + expect(headers["baggage"]).not.to be == nil + + # Extract using backend method: + extracted_context = Traces.extract(headers) + + # Should preserve baggage + expect(extracted_context.baggage).to be == baggage + end + end + + it "handles missing baggage gracefully in inject" do + context_without_baggage = Traces::Context.new(trace_id, parent_id, 0) + headers = {} + + Traces.inject(headers, context_without_baggage) + + # Should not have baggage header + expect(headers["baggage"]).to be == nil + end + end + end +end + +describe Traces do + with "#current_context" do + it "returns nil trace context when no active trace" do + # Clear any existing context first + Traces.trace_context = nil + current = Traces.current_context + expect(current).to be == nil + end + + it "captures current trace context when active" do + Traces.trace("test") do + current = Traces.current_context + expect(current).not.to be == nil + expect(current).to be == Traces.trace_context + end + end + + it "captures trace context at the time current is called" do + outer_current = nil + inner_current = nil + + Traces.trace("outer") do + outer_current = Traces.current_context + + Traces.trace("inner") do + inner_current = Traces.current_context + end + end + + expect(outer_current).not.to be == inner_current + end + + it "creates independent current objects" do + Traces.trace("test") do + current1 = Traces.current_context + current2 = Traces.current_context + + # Since current_context returns trace_context directly, + # they should be the same object + expect(current1).to be_equal(current2) + end + end + + it "isolates context between fibers" do + main_current = nil + fiber_current = nil + + Traces.trace("main") do + main_current = Traces.current_context + + Fiber.new do + # Fiber should start with no context + expect(Traces.current_context).to be == nil + + Traces.trace("fiber") do + fiber_current = Traces.current_context + end + end.resume + end + + expect(main_current).not.to be == fiber_current + end + end + + with "#with_context" do + it "can restore trace context from current" do + captured_current = nil + + # Clear any existing context first + Traces.trace_context = nil + + # First, capture a trace context + Traces.trace("original") do + captured_current = Traces.current_context + end + + # After trace, context remains (trace doesn't auto-restore previous context) + expect(Traces.trace_context).to be == captured_current + + # Clear context to test restoration + Traces.trace_context = nil + expect(Traces.trace_context).to be == nil + + # Restore the context and verify it's the same + Traces.with_context(captured_current) do + expect(Traces.trace_context).to be == captured_current + end + end + + it "restores previous context after block" do + # Clear any existing context first + Traces.trace_context = nil + original_context = Traces.trace_context + + Traces.trace("test") do + current = Traces.current_context + + # Clear context, then restore with with_context + Traces.trace_context = nil + + Traces.with_context(current) do + # Inside block should have the restored context + expect(Traces.trace_context).to be == current + end + + # After block, should be back to nil (what was set before with_context) + expect(Traces.trace_context).to be == nil + end + end + + it "can be called without a block" do + captured_current = nil + + Traces.trace("test") do + captured_current = Traces.current_context + end + + # Clear context + Traces.trace_context = nil + expect(Traces.trace_context).to be == nil + + # Set context without block (permanent switch) + Traces.with_context(captured_current) + expect(Traces.trace_context).to be == captured_current + end + + it "handles nil context gracefully" do + Traces.trace("test") do + # Should be able to switch to nil context + Traces.with_context(nil) do + expect(Traces.trace_context).to be == nil + end + end + end + + it "can nest with_context calls" do + first_current = nil + second_current = nil + + Traces.trace("first") do + first_current = Traces.current_context + + Traces.trace("second") do + second_current = Traces.current_context + end + end + + # Clear context to start fresh + Traces.trace_context = nil + + # Nested with_context calls + Traces.with_context(first_current) do + expect(Traces.trace_context).to be == first_current + + Traces.with_context(second_current) do + expect(Traces.trace_context).to be == second_current + end + + # Should be back to first context + expect(Traces.trace_context).to be == first_current + end + end + end + + with "#inject" do + it "can inject current context into headers" do + headers = {} + + Traces.trace("test") do + current = Traces.current_context + Traces.inject(headers, current) + end + + expect(headers["traceparent"]).not.to be == nil + expect(headers["traceparent"]).to be =~ /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{1,2}$/ + end + + it "can inject nil context (uses current trace_context)" do + headers = {} + + Traces.trace("test") do + Traces.inject(headers) + end + + expect(headers["traceparent"]).not.to be == nil + expect(headers["traceparent"]).to be =~ /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{1,2}$/ + end + + it "does nothing when no context available" do + # Clear any existing context first + Traces.trace_context = nil + headers = {} + Traces.inject(headers) + + expect(headers).to be == {} + end + + it "creates new headers hash when called without arguments" do + Traces.trace("test") do + # Call inject with no arguments - should create new hash + result = Traces.inject() + + expect(result).to be_a(Hash) + expect(result["traceparent"]).not.to be == nil + end + end + + it "returns nil when called without arguments and no active trace" do + # Clear any existing context first + Traces.trace_context = nil + + result = Traces.inject() + + expect(result).to be == nil + end + + it "mutates the headers hash" do + headers = {"Content-Type" => "application/json"} + original_headers = headers + + Traces.trace("test") do + result = Traces.inject(headers, Traces.current_context) + expect(result).to be_equal(headers) + end + + expect(headers["traceparent"]).not.to be == nil + expect(headers["Content-Type"]).to be == "application/json" + end + + + end + + with "#extract" do + it "can extract context from headers" do + original_context = nil + headers = {} + + # First, inject a context + Traces.trace("test") do + original_context = Traces.current_context + Traces.inject(headers, original_context) + end + + # Then extract it + extracted_context = Traces.extract(headers) + + expect(extracted_context).not.to be == nil + expect(extracted_context.trace_id).to be == original_context.trace_id + end + + it "can be used with with_context" do + original_context = nil + headers = {} + executed = false + + # Inject context: + Traces.trace("test") do + original_context = Traces.current_context + Traces.inject(headers, original_context) + end + + # Extract and use with with_context: + extracted_context = Traces.extract(headers) + Traces.with_context(extracted_context) do + executed = true + expect(Traces.trace_context.trace_id).to be == original_context.trace_id + end + + expect(executed).to be == true + end + + it "returns nil when no traceparent header" do + headers = {"Content-Type" => "application/json"} + context = Traces.extract(headers) + + expect(context).to be == nil + end + + + + it "returns nil for malformed traceparent" do + headers = {"traceparent" => "invalid-format"} + + result = Traces.extract(headers) + expect(result).to be == nil + end + end + + with "#inject and #extract round trip" do + it "preserves context through round trip" do + original_headers = {} + extracted_context = nil + + # Create and inject context + Traces.trace("parent") do + current = Traces.current_context + Traces.inject(original_headers, current) + + # Extract context: + extracted_context = Traces.extract(original_headers) + end + + # Use extracted context + Traces.with_context(extracted_context) do + Traces.trace("child") do + # Should be able to create child spans with extracted context + expect(Traces.trace_context).not.to be == nil + end + end + end end end From 6a369f08f2461047a5b99dad1cd2d240c06d19e9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 25 Aug 2025 15:14:30 +1200 Subject: [PATCH 2/3] Modernize code. --- .gitignore | 3 ++- bake.rb | 1 + gems.rb | 2 ++ lib/traces/config.rb | 2 +- test/traces/backend/.capture/app.rb | 2 +- test/traces/backend/.capture/bake.rb | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index bd2467d..a9ce52a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -/.bundle +/agent.md /.context +/.bundle /pkg /gems.locked /.covered.db diff --git a/bake.rb b/bake.rb index ada44f4..353f71b 100644 --- a/bake.rb +++ b/bake.rb @@ -9,4 +9,5 @@ def after_gem_release_version_increment(version) context["releases:update"].call(version) context["utopia:project:readme:update"].call + context["utopia:project:agent:context:update"].call end diff --git a/gems.rb b/gems.rb index 0d3a89d..e531696 100644 --- a/gems.rb +++ b/gems.rb @@ -13,6 +13,8 @@ gem "bake-gem" gem "bake-releases" + gem "agent-context" + gem "utopia-project" end diff --git a/lib/traces/config.rb b/lib/traces/config.rb index 7cc3ce3..d80b44e 100644 --- a/lib/traces/config.rb +++ b/lib/traces/config.rb @@ -13,7 +13,7 @@ class Config # @returns [Config] The loaded configuration. def self.load(path) config = self.new - + if File.exist?(path) config.instance_eval(File.read(path), path) end diff --git a/test/traces/backend/.capture/app.rb b/test/traces/backend/.capture/app.rb index f861978..84f52f1 100644 --- a/test/traces/backend/.capture/app.rb +++ b/test/traces/backend/.capture/app.rb @@ -1,5 +1,5 @@ # Released under the MIT License. -# Copyright, 2023, by Samuel Williams. +# Copyright, 2023-2025, by Samuel Williams. require 'traces/provider' diff --git a/test/traces/backend/.capture/bake.rb b/test/traces/backend/.capture/bake.rb index 7482602..131fe9f 100644 --- a/test/traces/backend/.capture/bake.rb +++ b/test/traces/backend/.capture/bake.rb @@ -1,5 +1,5 @@ # Released under the MIT License. -# Copyright, 2023, by Samuel Williams. +# Copyright, 2023-2025, by Samuel Williams. def environment require_relative 'app' From 34c81e5c43d9029a474928ab24f514d981048118 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 25 Aug 2025 15:19:53 +1200 Subject: [PATCH 3/3] Use test backend for testing. --- config/sus.rb | 2 +- lib/traces/backend/test.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/config/sus.rb b/config/sus.rb index a80b127..74451ab 100644 --- a/config/sus.rb +++ b/config/sus.rb @@ -3,7 +3,7 @@ # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. -ENV["TRACES_BACKEND"] ||= "traces/backend/console" +ENV["TRACES_BACKEND"] ||= "traces/backend/test" require "covered/sus" include Covered::Sus diff --git a/lib/traces/backend/test.rb b/lib/traces/backend/test.rb index 80ec614..737196c 100644 --- a/lib/traces/backend/test.rb +++ b/lib/traces/backend/test.rb @@ -85,8 +85,7 @@ def trace_context # @returns [Boolean] Whether there is an active trace. def active? - # For the sake of testing, we always enable tracing. - true + !!Fiber.current.traces_backend_context end end end