Signal Extensions
View SourceSignal extensions provide a way to add domain-specific metadata to Signals while maintaining CloudEvents v1.0.2 compliance. Extensions allow you to enrich Signals with custom functionality without modifying the core Signal structure.
Why Extensions?
Extensions solve the problem of adding custom metadata to Signals:
- Structured Metadata: Type-safe, validated custom data
- CloudEvents Compliance: Extensions become top-level CloudEvents attributes
- Composable: Multiple extensions can work together on a single Signal
- Backward Compatible: Signals without extensions work unchanged
Common use cases include:
- Threading: Track conversation threads in LLM systems
- Tracing: Add distributed tracing context
- Security: Include authentication/authorization data
- Routing: Custom dispatch configurations
Creating an Extension
Extensions are defined using the Jido.Signal.Ext behavior. Let's create a simple example:
defmodule MyApp.Signal.Ext.Thread do
@moduledoc """
Extension for tracking conversation threads in LLM interactions.
"""
use Jido.Signal.Ext,
namespace: "thread",
schema: [
id: [type: :string, required: true, doc: "Unique thread identifier"],
parent_id: [type: :string, doc: "Parent message ID for threading"]
]
endThat's it! The extension automatically:
- Registers itself in the extension registry
- Validates data using the schema
- Provides serialization to CloudEvents format
- Handles deserialization back to structured data
Using Extensions
Add extension data to a Signal:
# Create a Signal
{:ok, signal} = Jido.Signal.new("llm.conversation.message",
%{content: "Hello, how can I help?", role: "assistant"},
source: "/chat/session"
)
# Add thread extension using namespace STRING
{:ok, signal_with_thread} = Jido.Signal.put_extension(signal, "thread", %{
id: "thread-123",
parent_id: "msg-456"
})Retrieve extension data:
thread_data = Jido.Signal.get_extension(signal_with_thread, "thread")
# => %{id: "thread-123", parent_id: "msg-456"}
# Non-existent extension returns nil
missing = Jido.Signal.get_extension(signal_with_thread, "nonexistent")
# => nilList all extensions on a Signal:
extensions = Jido.Signal.list_extensions(signal_with_thread)
# => ["thread"]Remove an extension:
signal_without_thread = Jido.Signal.delete_extension(signal_with_thread, "thread")Built-in Dispatch Extension
Jido.Signal includes a built-in Dispatch extension that provides the same functionality as the legacy jido_dispatch field:
# Add dispatch configuration via extension using namespace STRING
{:ok, signal} = Jido.Signal.put_extension(signal, "dispatch",
{:pubsub, topic: "chat-events"}
)
# Multiple dispatch targets
{:ok, signal} = Jido.Signal.put_extension(signal, "dispatch", [
{:pubsub, topic: "events"},
{:logger, level: :info}
])CloudEvents Serialization
Extensions automatically serialize to CloudEvents-compliant top-level attributes:
# Signal with thread extension
signal = %Jido.Signal{
type: "llm.conversation.message",
source: "/chat",
data: %{content: "Hello"},
extensions: %{
"thread" => %{id: "thread-123", parent_id: "msg-456"}
}
}
# Serializes to CloudEvents JSON:
{:ok, json} = Jido.Signal.serialize(signal)Results in:
{
"specversion": "1.0.2",
"type": "llm.conversation.message",
"source": "/chat",
"id": "...",
"data": {"content": "Hello"},
"threadid": "thread-123",
"parentid": "msg-456"
}Custom Serialization
For more control over how extensions serialize, override the to_attrs/1 and from_attrs/1 callbacks:
defmodule MyApp.Signal.Ext.CustomTrace do
use Jido.Signal.Ext,
namespace: "trace",
schema: [
trace_id: [type: :string, required: true],
span_id: [type: :string, required: true],
parent_span_id: [type: :string]
]
# Custom serialization - multiple CloudEvents attributes
def to_attrs(%{trace_id: trace_id, span_id: span_id, parent_span_id: parent_span_id}) do
attrs = %{
"traceid" => trace_id,
"spanid" => span_id
}
if parent_span_id do
Map.put(attrs, "parentspan", parent_span_id)
else
attrs
end
end
# Custom deserialization
def from_attrs(attrs) do
case Map.get(attrs, "traceid") do
nil -> {:ok, nil}
trace_id ->
{:ok, %{
trace_id: trace_id,
span_id: Map.get(attrs, "spanid"),
parent_span_id: Map.get(attrs, "parentspan")
}}
end
end
endMultiple Extensions
Signals can have multiple extensions simultaneously:
{:ok, signal} = Jido.Signal.new("user.action", %{action: "login"})
# Add multiple extensions using namespace strings
{:ok, signal} = Jido.Signal.put_extension(signal, "thread", %{id: "session-123"})
{:ok, signal} = Jido.Signal.put_extension(signal, "trace", %{
trace_id: "trace-abc",
span_id: "span-def"
})
# All extensions are preserved during serialization/deserialization
{:ok, json} = Jido.Signal.serialize(signal)
{:ok, deserialized_signal} = Jido.Signal.deserialize(json)
# Extensions are fully restored
thread_data = Jido.Signal.get_extension(deserialized_signal, "thread")
trace_data = Jido.Signal.get_extension(deserialized_signal, "trace")Extension Guidelines
Namespace Rules
- Use lowercase names with optional dots (e.g., "auth", "trace", "auth.oauth")
- Keep names ≤ 20 characters (CloudEvents requirement)
- Only use
[a-z0-9]characters (CloudEvents requirement)
Schema Design
- Use NimbleOptions schema format
- Mark required fields with
required: true - Add documentation with
doc:option - Keep data structures simple for serialization
Example Patterns
Authentication Context:
defmodule MyApp.Signal.Ext.Auth do
use Jido.Signal.Ext,
namespace: "auth",
schema: [
user_id: [type: :string, required: true],
permissions: [type: {:list, :string}, default: []],
session_id: [type: :string]
]
endMetrics Collection:
defmodule MyApp.Signal.Ext.Metrics do
use Jido.Signal.Ext,
namespace: "metrics",
schema: [
duration_ms: [type: :integer],
memory_kb: [type: :integer],
tags: [type: :keyword_list, default: []]
]
endTesting Extensions
Test extensions like any other module:
defmodule MyApp.Signal.Ext.ThreadTest do
use ExUnit.Case, async: true
alias MyApp.Signal.Ext.Thread
test "validates required fields" do
assert {:ok, _} = Thread.new(%{id: "thread-123"})
assert {:error, _} = Thread.new(%{parent_id: "msg-456"}) # missing id
end
test "serialization round-trip" do
data = %{id: "thread-123", parent_id: "msg-456"}
# Serialize
attrs = Thread.to_attrs(data)
# Deserialize
{:ok, restored_data} = Thread.from_attrs(attrs)
assert data == restored_data
end
endError Handling and Safety
Jido Signal provides automatic error isolation for extensions to prevent corrupted extension data from affecting Signal processing.
The put_extension/3 function returns {:ok, signal} on success or {:error, reason} if validation fails:
# Successful extension data
{:ok, signal} = Jido.Signal.put_extension(signal, "thread", %{id: "thread-123"})
# Validation failure returns an error tuple
{:error, reason} = Jido.Signal.put_extension(signal, "thread", %{invalid: "data"})
# Unknown extension returns an error
{:error, "Unknown extension: unknown"} = Jido.Signal.put_extension(signal, "unknown", %{})The get_extension/2 function returns the data directly or nil:
# Extension data or nil - no tuple wrapping
thread_data = Jido.Signal.get_extension(signal, "thread")
# => %{id: "thread-123"} or nilThe system uses "safe" wrapper functions internally that:
- Catch and wrap exceptions from extension callbacks
- Log warnings for unknown extensions during deserialization
- Preserve Signal integrity even when extensions fail
- Allow graceful degradation of functionality
Unknown Extension Handling
When deserializing Signals with unknown extensions (extensions not registered in the current system), the serialization layer handles them gracefully:
# Signal from external system with unknown "customext" extension
json = """
{
"specversion": "1.0.2",
"type": "user.action",
"source": "/app",
"customextdata": "some-value"
}
"""
# Deserialization succeeds - unknown attributes are preserved
{:ok, signal} = Jido.Signal.deserialize(json)
# Unknown extension data is preserved as raw attributes
signal.extensions
# => %{"_unknown" => %{"customextdata" => "some-value"}}This is handled at the serialization/deserialization layer and ensures:
- Forward compatibility with future extensions
- Graceful handling of mixed-system environments
- Preservation of all CloudEvents data during round-trips
Best Practices
- Keep Extensions Simple: Focus on single responsibility
- Validate Early: Use comprehensive schemas to catch errors
- Test Serialization: Always test round-trip serialization
- Handle Errors Gracefully: Extensions may fail - design for resilience
- Document Usage: Provide clear examples in moduledocs
- Consider CloudEvents: Ensure attribute names follow CloudEvents rules
- Backward Compatibility: Design for evolution - avoid breaking changes
- Test Error Cases: Verify your application handles extension failures
Extensions provide a powerful way to add domain-specific functionality to Signals while maintaining standardization and interoperability. The built-in error isolation ensures your system remains robust even when dealing with corrupted or unknown extension data, making them ideal for building sophisticated event-driven systems that scale from simple applications to complex distributed architectures.
Next Steps
- Signal Journal - Durable append-only storage with causality tracking and replay capability
- Serialization - Convert signals to binary format for storage and transmission