edda-framework 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- edda/app.py +16 -1
- edda/hooks.py +11 -11
- edda/integrations/mcp/decorators.py +101 -5
- edda/integrations/mcp/server.py +36 -15
- edda/integrations/opentelemetry/__init__.py +39 -0
- edda/integrations/opentelemetry/hooks.py +579 -0
- {edda_framework-0.4.0.dist-info → edda_framework-0.6.0.dist-info}/METADATA +32 -5
- {edda_framework-0.4.0.dist-info → edda_framework-0.6.0.dist-info}/RECORD +11 -9
- {edda_framework-0.4.0.dist-info → edda_framework-0.6.0.dist-info}/WHEEL +0 -0
- {edda_framework-0.4.0.dist-info → edda_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.4.0.dist-info → edda_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
edda/app.py
CHANGED
|
@@ -238,7 +238,9 @@ class EddaApp:
|
|
|
238
238
|
Register a default CloudEvent handler for a workflow.
|
|
239
239
|
|
|
240
240
|
The default handler extracts the CloudEvent data and passes it
|
|
241
|
-
as kwargs to workflow.start().
|
|
241
|
+
as kwargs to workflow.start(). If the CloudEvent contains
|
|
242
|
+
traceparent/tracestate extension attributes (for distributed tracing),
|
|
243
|
+
they are automatically injected into _trace_context.
|
|
242
244
|
|
|
243
245
|
Args:
|
|
244
246
|
event_type: CloudEvent type (same as workflow name)
|
|
@@ -250,11 +252,24 @@ class EddaApp:
|
|
|
250
252
|
# Extract data from CloudEvent
|
|
251
253
|
data = event.get_data()
|
|
252
254
|
|
|
255
|
+
# Extract trace context from CloudEvent extension attributes
|
|
256
|
+
# (W3C Trace Context: traceparent, tracestate)
|
|
257
|
+
trace_context: dict[str, str] = {}
|
|
258
|
+
attrs = event.get_attributes()
|
|
259
|
+
if "traceparent" in attrs:
|
|
260
|
+
trace_context["traceparent"] = str(attrs["traceparent"])
|
|
261
|
+
if "tracestate" in attrs:
|
|
262
|
+
trace_context["tracestate"] = str(attrs["tracestate"])
|
|
263
|
+
|
|
253
264
|
# Start workflow with data as kwargs
|
|
254
265
|
if isinstance(data, dict):
|
|
266
|
+
# Inject trace context if present
|
|
267
|
+
if trace_context:
|
|
268
|
+
data = {**data, "_trace_context": trace_context}
|
|
255
269
|
await wf.start(**data)
|
|
256
270
|
else:
|
|
257
271
|
# If data is not a dict, start without arguments
|
|
272
|
+
# (trace context cannot be injected)
|
|
258
273
|
await wf.start()
|
|
259
274
|
|
|
260
275
|
# Register the handler
|
edda/hooks.py
CHANGED
|
@@ -12,8 +12,8 @@ Example:
|
|
|
12
12
|
... async def on_workflow_start(self, instance_id, workflow_name, input_data):
|
|
13
13
|
... print(f"Workflow {workflow_name} started: {instance_id}")
|
|
14
14
|
...
|
|
15
|
-
... async def on_activity_complete(self, instance_id,
|
|
16
|
-
... print(f"Activity {activity_name} completed (cache_hit={cache_hit})")
|
|
15
|
+
... async def on_activity_complete(self, instance_id, activity_id, activity_name, result, cache_hit):
|
|
16
|
+
... print(f"Activity {activity_name} ({activity_id}) completed (cache_hit={cache_hit})")
|
|
17
17
|
>>>
|
|
18
18
|
>>> app = EddaApp(service_name="my-service", db_url="...", hooks=MyHooks())
|
|
19
19
|
"""
|
|
@@ -86,7 +86,7 @@ class WorkflowHooks(Protocol):
|
|
|
86
86
|
async def on_activity_start(
|
|
87
87
|
self,
|
|
88
88
|
instance_id: str,
|
|
89
|
-
|
|
89
|
+
activity_id: str,
|
|
90
90
|
activity_name: str,
|
|
91
91
|
is_replaying: bool,
|
|
92
92
|
) -> None:
|
|
@@ -95,7 +95,7 @@ class WorkflowHooks(Protocol):
|
|
|
95
95
|
|
|
96
96
|
Args:
|
|
97
97
|
instance_id: Unique workflow instance ID
|
|
98
|
-
|
|
98
|
+
activity_id: Activity ID (e.g., "reserve_inventory:1")
|
|
99
99
|
activity_name: Name of the activity function
|
|
100
100
|
is_replaying: True if this is a replay (cached result)
|
|
101
101
|
"""
|
|
@@ -104,7 +104,7 @@ class WorkflowHooks(Protocol):
|
|
|
104
104
|
async def on_activity_complete(
|
|
105
105
|
self,
|
|
106
106
|
instance_id: str,
|
|
107
|
-
|
|
107
|
+
activity_id: str,
|
|
108
108
|
activity_name: str,
|
|
109
109
|
result: Any,
|
|
110
110
|
cache_hit: bool,
|
|
@@ -114,7 +114,7 @@ class WorkflowHooks(Protocol):
|
|
|
114
114
|
|
|
115
115
|
Args:
|
|
116
116
|
instance_id: Unique workflow instance ID
|
|
117
|
-
|
|
117
|
+
activity_id: Activity ID (e.g., "reserve_inventory:1")
|
|
118
118
|
activity_name: Name of the activity function
|
|
119
119
|
result: Return value from the activity
|
|
120
120
|
cache_hit: True if result was retrieved from cache (replay)
|
|
@@ -124,7 +124,7 @@ class WorkflowHooks(Protocol):
|
|
|
124
124
|
async def on_activity_failed(
|
|
125
125
|
self,
|
|
126
126
|
instance_id: str,
|
|
127
|
-
|
|
127
|
+
activity_id: str,
|
|
128
128
|
activity_name: str,
|
|
129
129
|
error: Exception,
|
|
130
130
|
) -> None:
|
|
@@ -133,7 +133,7 @@ class WorkflowHooks(Protocol):
|
|
|
133
133
|
|
|
134
134
|
Args:
|
|
135
135
|
instance_id: Unique workflow instance ID
|
|
136
|
-
|
|
136
|
+
activity_id: Activity ID (e.g., "reserve_inventory:1")
|
|
137
137
|
activity_name: Name of the activity function
|
|
138
138
|
error: Exception that caused the failure
|
|
139
139
|
"""
|
|
@@ -231,7 +231,7 @@ class HooksBase(WorkflowHooks, ABC):
|
|
|
231
231
|
async def on_activity_start(
|
|
232
232
|
self,
|
|
233
233
|
instance_id: str,
|
|
234
|
-
|
|
234
|
+
activity_id: str,
|
|
235
235
|
activity_name: str,
|
|
236
236
|
is_replaying: bool,
|
|
237
237
|
) -> None:
|
|
@@ -240,7 +240,7 @@ class HooksBase(WorkflowHooks, ABC):
|
|
|
240
240
|
async def on_activity_complete(
|
|
241
241
|
self,
|
|
242
242
|
instance_id: str,
|
|
243
|
-
|
|
243
|
+
activity_id: str,
|
|
244
244
|
activity_name: str,
|
|
245
245
|
result: Any,
|
|
246
246
|
cache_hit: bool,
|
|
@@ -250,7 +250,7 @@ class HooksBase(WorkflowHooks, ABC):
|
|
|
250
250
|
async def on_activity_failed(
|
|
251
251
|
self,
|
|
252
252
|
instance_id: str,
|
|
253
|
-
|
|
253
|
+
activity_id: str,
|
|
254
254
|
activity_name: str,
|
|
255
255
|
error: Exception,
|
|
256
256
|
) -> None:
|
|
@@ -19,14 +19,15 @@ def create_durable_tool(
|
|
|
19
19
|
description: str = "",
|
|
20
20
|
) -> Workflow:
|
|
21
21
|
"""
|
|
22
|
-
Create a durable workflow tool with auto-generated status/result tools.
|
|
22
|
+
Create a durable workflow tool with auto-generated status/result/cancel tools.
|
|
23
23
|
|
|
24
24
|
This function:
|
|
25
25
|
1. Wraps the function as an Edda @workflow
|
|
26
|
-
2. Registers
|
|
26
|
+
2. Registers four MCP tools:
|
|
27
27
|
- {name}: Start workflow, return instance_id
|
|
28
28
|
- {name}_status: Check workflow status
|
|
29
29
|
- {name}_result: Get workflow result
|
|
30
|
+
- {name}_cancel: Cancel workflow (if running or waiting)
|
|
30
31
|
|
|
31
32
|
Args:
|
|
32
33
|
server: EddaMCPServer instance
|
|
@@ -93,9 +94,9 @@ def create_durable_tool(
|
|
|
93
94
|
status_tool_name = f"{workflow_name}_status"
|
|
94
95
|
status_tool_description = f"Check status of {workflow_name} workflow"
|
|
95
96
|
|
|
96
|
-
@server._mcp.tool(name=status_tool_name, description=status_tool_description)
|
|
97
|
+
@server._mcp.tool(name=status_tool_name, description=status_tool_description)
|
|
97
98
|
async def status_tool(instance_id: str) -> dict[str, Any]:
|
|
98
|
-
"""Check workflow status."""
|
|
99
|
+
"""Check workflow status with progress metadata."""
|
|
99
100
|
try:
|
|
100
101
|
instance = await server.storage.get_instance(instance_id)
|
|
101
102
|
if instance is None:
|
|
@@ -112,9 +113,22 @@ def create_durable_tool(
|
|
|
112
113
|
status = instance["status"]
|
|
113
114
|
current_activity_id = instance.get("current_activity_id", "N/A")
|
|
114
115
|
|
|
116
|
+
# Get history to count completed activities
|
|
117
|
+
history = await server.storage.get_history(instance_id)
|
|
118
|
+
completed_activities = len(
|
|
119
|
+
[h for h in history if h["event_type"] == "ActivityCompleted"]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Suggest poll interval based on status
|
|
123
|
+
# Running workflows need more frequent polling (5s)
|
|
124
|
+
# Waiting workflows need less frequent polling (10s)
|
|
125
|
+
suggested_poll_interval_ms = 5000 if status == "running" else 10000
|
|
126
|
+
|
|
115
127
|
status_text = (
|
|
116
128
|
f"Workflow Status: {status}\n"
|
|
117
129
|
f"Current Activity: {current_activity_id}\n"
|
|
130
|
+
f"Completed Activities: {completed_activities}\n"
|
|
131
|
+
f"Suggested Poll Interval: {suggested_poll_interval_ms}ms\n"
|
|
118
132
|
f"Instance ID: {instance_id}"
|
|
119
133
|
)
|
|
120
134
|
|
|
@@ -137,7 +151,7 @@ def create_durable_tool(
|
|
|
137
151
|
result_tool_name = f"{workflow_name}_result"
|
|
138
152
|
result_tool_description = f"Get result of {workflow_name} workflow (if completed)"
|
|
139
153
|
|
|
140
|
-
@server._mcp.tool(name=result_tool_name, description=result_tool_description)
|
|
154
|
+
@server._mcp.tool(name=result_tool_name, description=result_tool_description)
|
|
141
155
|
async def result_tool(instance_id: str) -> dict[str, Any]:
|
|
142
156
|
"""Get workflow result (if completed)."""
|
|
143
157
|
try:
|
|
@@ -184,4 +198,86 @@ def create_durable_tool(
|
|
|
184
198
|
"isError": True,
|
|
185
199
|
}
|
|
186
200
|
|
|
201
|
+
# 5. Generate cancel tool
|
|
202
|
+
cancel_tool_name = f"{workflow_name}_cancel"
|
|
203
|
+
cancel_tool_description = f"Cancel {workflow_name} workflow (if running or waiting)"
|
|
204
|
+
|
|
205
|
+
@server._mcp.tool(name=cancel_tool_name, description=cancel_tool_description)
|
|
206
|
+
async def cancel_tool(instance_id: str) -> dict[str, Any]:
|
|
207
|
+
"""Cancel a running or waiting workflow."""
|
|
208
|
+
try:
|
|
209
|
+
# Check if instance exists
|
|
210
|
+
instance = await server.storage.get_instance(instance_id)
|
|
211
|
+
if instance is None:
|
|
212
|
+
return {
|
|
213
|
+
"content": [
|
|
214
|
+
{
|
|
215
|
+
"type": "text",
|
|
216
|
+
"text": f"Workflow instance not found: {instance_id}",
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"isError": True,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
current_status = instance["status"]
|
|
223
|
+
|
|
224
|
+
# Check if replay_engine is available
|
|
225
|
+
if server.replay_engine is None:
|
|
226
|
+
return {
|
|
227
|
+
"content": [
|
|
228
|
+
{
|
|
229
|
+
"type": "text",
|
|
230
|
+
"text": "Server not initialized. Call server.initialize() first.",
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
"isError": True,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Try to cancel
|
|
237
|
+
success = await server.replay_engine.cancel_workflow(
|
|
238
|
+
instance_id=instance_id,
|
|
239
|
+
cancelled_by="mcp_user",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if success:
|
|
243
|
+
return {
|
|
244
|
+
"content": [
|
|
245
|
+
{
|
|
246
|
+
"type": "text",
|
|
247
|
+
"text": (
|
|
248
|
+
f"Workflow '{workflow_name}' cancelled successfully.\n"
|
|
249
|
+
f"Instance ID: {instance_id}\n"
|
|
250
|
+
f"Compensations executed.\n\n"
|
|
251
|
+
f"The workflow has been stopped and any side effects "
|
|
252
|
+
f"have been rolled back."
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
],
|
|
256
|
+
"isError": False,
|
|
257
|
+
}
|
|
258
|
+
else:
|
|
259
|
+
return {
|
|
260
|
+
"content": [
|
|
261
|
+
{
|
|
262
|
+
"type": "text",
|
|
263
|
+
"text": (
|
|
264
|
+
f"Cannot cancel workflow: {instance_id}\n"
|
|
265
|
+
f"Current status: {current_status}\n"
|
|
266
|
+
f"Only running or waiting workflows can be cancelled."
|
|
267
|
+
),
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
"isError": True,
|
|
271
|
+
}
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return {
|
|
274
|
+
"content": [
|
|
275
|
+
{
|
|
276
|
+
"type": "text",
|
|
277
|
+
"text": f"Error cancelling workflow: {str(e)}",
|
|
278
|
+
}
|
|
279
|
+
],
|
|
280
|
+
"isError": True,
|
|
281
|
+
}
|
|
282
|
+
|
|
187
283
|
return workflow_instance
|
edda/integrations/mcp/server.py
CHANGED
|
@@ -9,10 +9,11 @@ from edda.app import EddaApp
|
|
|
9
9
|
from edda.workflow import Workflow
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
+
from edda.replay import ReplayEngine
|
|
12
13
|
from edda.storage.protocol import StorageProtocol
|
|
13
14
|
|
|
14
15
|
try:
|
|
15
|
-
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
17
|
except ImportError as e:
|
|
17
18
|
raise ImportError(
|
|
18
19
|
"MCP Python SDK is required for MCP integration. "
|
|
@@ -68,10 +69,11 @@ class EddaMCPServer:
|
|
|
68
69
|
asyncio.run(main())
|
|
69
70
|
```
|
|
70
71
|
|
|
71
|
-
The server automatically generates
|
|
72
|
+
The server automatically generates four MCP tools for each @durable_tool:
|
|
72
73
|
- `tool_name`: Start the workflow, returns instance_id
|
|
73
74
|
- `tool_name_status`: Check workflow status
|
|
74
75
|
- `tool_name_result`: Get workflow result (if completed)
|
|
76
|
+
- `tool_name_cancel`: Cancel workflow (if running or waiting)
|
|
75
77
|
"""
|
|
76
78
|
|
|
77
79
|
def __init__(
|
|
@@ -122,6 +124,24 @@ class EddaMCPServer:
|
|
|
122
124
|
"""
|
|
123
125
|
return self._edda_app.storage
|
|
124
126
|
|
|
127
|
+
@property
|
|
128
|
+
def replay_engine(self) -> ReplayEngine | None:
|
|
129
|
+
"""
|
|
130
|
+
Access replay engine for workflow operations (cancel, resume, etc.).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
ReplayEngine or None if not initialized
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
```python
|
|
137
|
+
# Cancel a running workflow
|
|
138
|
+
success = await server.replay_engine.cancel_workflow(
|
|
139
|
+
instance_id, "mcp_user"
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
return self._edda_app.replay_engine
|
|
144
|
+
|
|
125
145
|
def durable_tool(
|
|
126
146
|
self,
|
|
127
147
|
func: Callable[..., Any] | None = None,
|
|
@@ -131,10 +151,11 @@ class EddaMCPServer:
|
|
|
131
151
|
"""
|
|
132
152
|
Decorator to define a durable workflow tool.
|
|
133
153
|
|
|
134
|
-
Automatically generates
|
|
154
|
+
Automatically generates four MCP tools:
|
|
135
155
|
1. Main tool: Starts the workflow, returns instance_id
|
|
136
156
|
2. Status tool: Checks workflow status
|
|
137
157
|
3. Result tool: Gets workflow result (if completed)
|
|
158
|
+
4. Cancel tool: Cancels workflow (if running or waiting)
|
|
138
159
|
|
|
139
160
|
Args:
|
|
140
161
|
func: Workflow function (async)
|
|
@@ -207,7 +228,7 @@ class EddaMCPServer:
|
|
|
207
228
|
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
208
229
|
# Use FastMCP's native prompt decorator
|
|
209
230
|
prompt_desc = description or f.__doc__ or f"Prompt: {f.__name__}"
|
|
210
|
-
return
|
|
231
|
+
return self._mcp.prompt(description=prompt_desc)(f)
|
|
211
232
|
|
|
212
233
|
if func is None:
|
|
213
234
|
return decorator
|
|
@@ -228,8 +249,8 @@ class EddaMCPServer:
|
|
|
228
249
|
Returns:
|
|
229
250
|
ASGI callable (Starlette app)
|
|
230
251
|
"""
|
|
231
|
-
from starlette.requests import Request
|
|
232
|
-
from starlette.responses import Response
|
|
252
|
+
from starlette.requests import Request
|
|
253
|
+
from starlette.responses import Response
|
|
233
254
|
|
|
234
255
|
# Get MCP's Starlette app (Issue #1367 workaround: use directly)
|
|
235
256
|
app = self._mcp.streamable_http_app()
|
|
@@ -270,14 +291,13 @@ class EddaMCPServer:
|
|
|
270
291
|
app.router.add_route("/cancel/{instance_id}", edda_cancel_handler, methods=["POST"])
|
|
271
292
|
|
|
272
293
|
# Add authentication middleware if token_verifier provided (AFTER adding routes)
|
|
294
|
+
result_app: Any = app
|
|
273
295
|
if self._token_verifier is not None:
|
|
274
|
-
from starlette.middleware.base import
|
|
275
|
-
BaseHTTPMiddleware,
|
|
276
|
-
)
|
|
296
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
277
297
|
|
|
278
|
-
class AuthMiddleware(BaseHTTPMiddleware):
|
|
279
|
-
def __init__(self,
|
|
280
|
-
super().__init__(
|
|
298
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
299
|
+
def __init__(self, app_inner: Any, token_verifier: Callable[[str], bool]) -> None:
|
|
300
|
+
super().__init__(app_inner)
|
|
281
301
|
self.token_verifier = token_verifier
|
|
282
302
|
|
|
283
303
|
async def dispatch(
|
|
@@ -288,12 +308,13 @@ class EddaMCPServer:
|
|
|
288
308
|
token = auth_header[7:]
|
|
289
309
|
if not self.token_verifier(token):
|
|
290
310
|
return Response("Unauthorized", status_code=401)
|
|
291
|
-
|
|
311
|
+
response: Response = await call_next(request)
|
|
312
|
+
return response
|
|
292
313
|
|
|
293
314
|
# Wrap app with auth middleware
|
|
294
|
-
|
|
315
|
+
result_app = AuthMiddleware(app, self._token_verifier)
|
|
295
316
|
|
|
296
|
-
return cast(Callable[..., Any],
|
|
317
|
+
return cast(Callable[..., Any], result_app)
|
|
297
318
|
|
|
298
319
|
async def initialize(self) -> None:
|
|
299
320
|
"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Edda OpenTelemetry Integration.
|
|
3
|
+
|
|
4
|
+
Provides OpenTelemetry tracing and optional metrics for Edda workflows.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
```python
|
|
8
|
+
from edda import EddaApp
|
|
9
|
+
from edda.integrations.opentelemetry import OpenTelemetryHooks
|
|
10
|
+
|
|
11
|
+
hooks = OpenTelemetryHooks(
|
|
12
|
+
service_name="order-service",
|
|
13
|
+
otlp_endpoint="http://localhost:4317", # Optional
|
|
14
|
+
enable_metrics=True, # Optional
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app = EddaApp(
|
|
18
|
+
service_name="order-service",
|
|
19
|
+
db_url="sqlite:///workflow.db",
|
|
20
|
+
hooks=hooks,
|
|
21
|
+
)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Installation:
|
|
25
|
+
```bash
|
|
26
|
+
pip install edda-framework[opentelemetry]
|
|
27
|
+
|
|
28
|
+
# Or using uv
|
|
29
|
+
uv add edda-framework --extra opentelemetry
|
|
30
|
+
```
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from edda.integrations.opentelemetry.hooks import (
|
|
34
|
+
OpenTelemetryHooks,
|
|
35
|
+
extract_trace_context,
|
|
36
|
+
inject_trace_context,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = ["OpenTelemetryHooks", "inject_trace_context", "extract_trace_context"]
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry hooks implementation for Edda workflows.
|
|
3
|
+
|
|
4
|
+
This module provides the OpenTelemetryHooks class that integrates OpenTelemetry
|
|
5
|
+
tracing and optional metrics with Edda's workflow execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from edda.hooks import HooksBase
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from opentelemetry.context import Context
|
|
17
|
+
from opentelemetry.trace import Span, Tracer
|
|
18
|
+
|
|
19
|
+
# Check if OpenTelemetry is available
|
|
20
|
+
try:
|
|
21
|
+
from opentelemetry import trace
|
|
22
|
+
from opentelemetry.context import Context
|
|
23
|
+
from opentelemetry.sdk.resources import Resource
|
|
24
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
25
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
26
|
+
from opentelemetry.trace import Span, Status, StatusCode, Tracer
|
|
27
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
28
|
+
|
|
29
|
+
_OPENTELEMETRY_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_OPENTELEMETRY_AVAILABLE = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OpenTelemetryHooks(HooksBase):
|
|
35
|
+
"""
|
|
36
|
+
OpenTelemetry tracing and metrics integration for Edda workflows.
|
|
37
|
+
|
|
38
|
+
Creates distributed traces with:
|
|
39
|
+
- Workflow spans as parent spans
|
|
40
|
+
- Activity spans as child spans
|
|
41
|
+
- Error recording and status propagation
|
|
42
|
+
- Retry event tracking
|
|
43
|
+
- Optional metrics (counters, histograms)
|
|
44
|
+
|
|
45
|
+
Span Hierarchy::
|
|
46
|
+
|
|
47
|
+
workflow:order_workflow (parent)
|
|
48
|
+
├── activity:reserve_inventory (child)
|
|
49
|
+
│ └── [event: retry] (if retry occurs)
|
|
50
|
+
├── activity:process_payment (child)
|
|
51
|
+
└── activity:ship_order (child)
|
|
52
|
+
└── [event: event_received] (if wait_event used)
|
|
53
|
+
|
|
54
|
+
Example::
|
|
55
|
+
|
|
56
|
+
from edda import EddaApp
|
|
57
|
+
from edda.integrations.opentelemetry import OpenTelemetryHooks
|
|
58
|
+
|
|
59
|
+
hooks = OpenTelemetryHooks(
|
|
60
|
+
service_name="order-service",
|
|
61
|
+
otlp_endpoint="http://localhost:4317", # Optional
|
|
62
|
+
enable_metrics=True, # Optional
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
app = EddaApp(
|
|
66
|
+
service_name="order-service",
|
|
67
|
+
db_url="sqlite:///workflow.db",
|
|
68
|
+
hooks=hooks,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
service.name: Service name for resource identification
|
|
73
|
+
service.version: Service version (default: "1.0.0")
|
|
74
|
+
edda.framework: Always "true" to identify Edda workflows
|
|
75
|
+
|
|
76
|
+
Installation::
|
|
77
|
+
|
|
78
|
+
pip install edda-framework[opentelemetry]
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
service_name: str = "edda",
|
|
84
|
+
otlp_endpoint: str | None = None,
|
|
85
|
+
enable_metrics: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Initialize OpenTelemetry hooks.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
service_name: Service name for resource identification
|
|
92
|
+
otlp_endpoint: OTLP endpoint URL (e.g., "http://localhost:4317").
|
|
93
|
+
If None, uses ConsoleSpanExporter for local development.
|
|
94
|
+
enable_metrics: Enable OpenTelemetry metrics (counters, histograms)
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ImportError: If OpenTelemetry packages are not installed
|
|
98
|
+
"""
|
|
99
|
+
if not _OPENTELEMETRY_AVAILABLE:
|
|
100
|
+
raise ImportError(
|
|
101
|
+
"OpenTelemetry packages are not installed. "
|
|
102
|
+
"Install them with: pip install edda-framework[opentelemetry]"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self._tracer = self._setup_tracing(service_name, otlp_endpoint)
|
|
106
|
+
self._propagator = TraceContextTextMapPropagator()
|
|
107
|
+
|
|
108
|
+
# Span lifecycle management
|
|
109
|
+
self._workflow_spans: dict[str, Span] = {}
|
|
110
|
+
self._activity_spans: dict[str, Span] = {}
|
|
111
|
+
self._workflow_start_times: dict[str, float] = {}
|
|
112
|
+
self._activity_start_times: dict[str, float] = {}
|
|
113
|
+
|
|
114
|
+
# Optional metrics
|
|
115
|
+
self._enable_metrics = enable_metrics
|
|
116
|
+
if enable_metrics:
|
|
117
|
+
self._setup_metrics(service_name, otlp_endpoint)
|
|
118
|
+
|
|
119
|
+
def _setup_tracing(self, service_name: str, otlp_endpoint: str | None) -> Tracer:
|
|
120
|
+
"""Configure OpenTelemetry tracing.
|
|
121
|
+
|
|
122
|
+
If a TracerProvider is already configured (e.g., by ASGI/WSGI middleware),
|
|
123
|
+
it will be reused instead of creating a new one. This enables trace context
|
|
124
|
+
propagation from external sources.
|
|
125
|
+
"""
|
|
126
|
+
from opentelemetry.trace import NoOpTracerProvider
|
|
127
|
+
|
|
128
|
+
# Check if a TracerProvider is already configured
|
|
129
|
+
existing_provider = trace.get_tracer_provider()
|
|
130
|
+
|
|
131
|
+
# Only create new provider if none exists (NoOpTracerProvider is the default)
|
|
132
|
+
if isinstance(existing_provider, NoOpTracerProvider):
|
|
133
|
+
resource = Resource.create(
|
|
134
|
+
{
|
|
135
|
+
"service.name": service_name,
|
|
136
|
+
"service.version": "1.0.0",
|
|
137
|
+
"edda.framework": "true",
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
provider = TracerProvider(resource=resource)
|
|
142
|
+
|
|
143
|
+
if otlp_endpoint:
|
|
144
|
+
# Production: OTLP exporter
|
|
145
|
+
try:
|
|
146
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
147
|
+
OTLPSpanExporter,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
|
|
151
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
152
|
+
except ImportError:
|
|
153
|
+
# Fallback to console if OTLP exporter not installed
|
|
154
|
+
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
155
|
+
else:
|
|
156
|
+
# Development: Console exporter
|
|
157
|
+
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
158
|
+
|
|
159
|
+
trace.set_tracer_provider(provider)
|
|
160
|
+
|
|
161
|
+
# Always get tracer from current provider (whether new or existing)
|
|
162
|
+
return trace.get_tracer("edda.opentelemetry", "1.0.0")
|
|
163
|
+
|
|
164
|
+
def _setup_metrics(self, service_name: str, otlp_endpoint: str | None) -> None:
|
|
165
|
+
"""Configure OpenTelemetry metrics (optional)."""
|
|
166
|
+
try:
|
|
167
|
+
from opentelemetry import metrics
|
|
168
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
169
|
+
from opentelemetry.sdk.metrics.export import (
|
|
170
|
+
ConsoleMetricExporter,
|
|
171
|
+
MetricExporter,
|
|
172
|
+
PeriodicExportingMetricReader,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
exporter: MetricExporter
|
|
176
|
+
if otlp_endpoint:
|
|
177
|
+
try:
|
|
178
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
|
179
|
+
OTLPMetricExporter,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
exporter = OTLPMetricExporter(endpoint=otlp_endpoint, insecure=True)
|
|
183
|
+
except ImportError:
|
|
184
|
+
exporter = ConsoleMetricExporter()
|
|
185
|
+
else:
|
|
186
|
+
exporter = ConsoleMetricExporter()
|
|
187
|
+
|
|
188
|
+
reader = PeriodicExportingMetricReader(exporter, export_interval_millis=10000)
|
|
189
|
+
resource = Resource.create({"service.name": service_name})
|
|
190
|
+
provider = MeterProvider(resource=resource, metric_readers=[reader])
|
|
191
|
+
metrics.set_meter_provider(provider)
|
|
192
|
+
|
|
193
|
+
meter = metrics.get_meter("edda.opentelemetry", "1.0.0")
|
|
194
|
+
|
|
195
|
+
# Counters
|
|
196
|
+
self._workflow_started_counter = meter.create_counter(
|
|
197
|
+
"edda.workflow.started",
|
|
198
|
+
description="Number of workflows started",
|
|
199
|
+
unit="1",
|
|
200
|
+
)
|
|
201
|
+
self._workflow_completed_counter = meter.create_counter(
|
|
202
|
+
"edda.workflow.completed",
|
|
203
|
+
description="Number of workflows completed",
|
|
204
|
+
unit="1",
|
|
205
|
+
)
|
|
206
|
+
self._workflow_failed_counter = meter.create_counter(
|
|
207
|
+
"edda.workflow.failed",
|
|
208
|
+
description="Number of workflows failed",
|
|
209
|
+
unit="1",
|
|
210
|
+
)
|
|
211
|
+
self._activity_executed_counter = meter.create_counter(
|
|
212
|
+
"edda.activity.executed",
|
|
213
|
+
description="Number of activities executed (not cache hit)",
|
|
214
|
+
unit="1",
|
|
215
|
+
)
|
|
216
|
+
self._activity_cache_hit_counter = meter.create_counter(
|
|
217
|
+
"edda.activity.cache_hit",
|
|
218
|
+
description="Number of activity cache hits (replay)",
|
|
219
|
+
unit="1",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Histograms
|
|
223
|
+
self._workflow_duration_histogram = meter.create_histogram(
|
|
224
|
+
"edda.workflow.duration",
|
|
225
|
+
description="Workflow execution duration",
|
|
226
|
+
unit="s",
|
|
227
|
+
)
|
|
228
|
+
self._activity_duration_histogram = meter.create_histogram(
|
|
229
|
+
"edda.activity.duration",
|
|
230
|
+
description="Activity execution duration",
|
|
231
|
+
unit="s",
|
|
232
|
+
)
|
|
233
|
+
except ImportError:
|
|
234
|
+
self._enable_metrics = False
|
|
235
|
+
|
|
236
|
+
# =========================================================================
|
|
237
|
+
# Workflow Hooks
|
|
238
|
+
# =========================================================================
|
|
239
|
+
|
|
240
|
+
async def on_workflow_start(
|
|
241
|
+
self, instance_id: str, workflow_name: str, input_data: dict[str, Any]
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Start a workflow span (parent for all activities).
|
|
244
|
+
|
|
245
|
+
Trace context is inherited in the following priority:
|
|
246
|
+
1. Explicit _trace_context in input_data (e.g., from CloudEvents)
|
|
247
|
+
2. Current active span (e.g., from ASGI/WSGI middleware)
|
|
248
|
+
3. None (creates a new root span)
|
|
249
|
+
"""
|
|
250
|
+
# Priority 1: Extract trace context from input_data (CloudEvents, manual)
|
|
251
|
+
parent_context = self._extract_trace_context(input_data)
|
|
252
|
+
|
|
253
|
+
# Priority 2: Inherit from current active span (ASGI/WSGI middleware)
|
|
254
|
+
if parent_context is None:
|
|
255
|
+
current_span = trace.get_current_span()
|
|
256
|
+
if current_span.is_recording():
|
|
257
|
+
parent_context = trace.set_span_in_context(current_span)
|
|
258
|
+
|
|
259
|
+
span = self._tracer.start_span(
|
|
260
|
+
name=f"workflow:{workflow_name}",
|
|
261
|
+
context=parent_context,
|
|
262
|
+
attributes={
|
|
263
|
+
"edda.workflow.instance_id": instance_id,
|
|
264
|
+
"edda.workflow.name": workflow_name,
|
|
265
|
+
"edda.workflow.input_keys": str(list(input_data.keys())),
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
self._workflow_spans[instance_id] = span
|
|
269
|
+
self._workflow_start_times[instance_id] = time.time()
|
|
270
|
+
|
|
271
|
+
# Metrics
|
|
272
|
+
if self._enable_metrics:
|
|
273
|
+
self._workflow_started_counter.add(1, {"workflow_name": workflow_name})
|
|
274
|
+
|
|
275
|
+
async def on_workflow_complete(
|
|
276
|
+
self, instance_id: str, workflow_name: str, result: Any # noqa: ARG002
|
|
277
|
+
) -> None:
|
|
278
|
+
"""End workflow span with success status."""
|
|
279
|
+
span = self._workflow_spans.pop(instance_id, None)
|
|
280
|
+
if span:
|
|
281
|
+
span.set_status(Status(StatusCode.OK))
|
|
282
|
+
span.end()
|
|
283
|
+
|
|
284
|
+
# Always cleanup start time
|
|
285
|
+
start_time = self._workflow_start_times.pop(instance_id, None)
|
|
286
|
+
|
|
287
|
+
# Metrics
|
|
288
|
+
if self._enable_metrics:
|
|
289
|
+
self._workflow_completed_counter.add(1, {"workflow_name": workflow_name})
|
|
290
|
+
if start_time:
|
|
291
|
+
duration = time.time() - start_time
|
|
292
|
+
self._workflow_duration_histogram.record(
|
|
293
|
+
duration, {"workflow_name": workflow_name, "status": "completed"}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def on_workflow_failed(
|
|
297
|
+
self, instance_id: str, workflow_name: str, error: Exception
|
|
298
|
+
) -> None:
|
|
299
|
+
"""End workflow span with error status."""
|
|
300
|
+
span = self._workflow_spans.pop(instance_id, None)
|
|
301
|
+
if span:
|
|
302
|
+
span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
303
|
+
span.record_exception(error)
|
|
304
|
+
span.end()
|
|
305
|
+
|
|
306
|
+
# Always cleanup start time
|
|
307
|
+
start_time = self._workflow_start_times.pop(instance_id, None)
|
|
308
|
+
|
|
309
|
+
# Metrics
|
|
310
|
+
if self._enable_metrics:
|
|
311
|
+
self._workflow_failed_counter.add(
|
|
312
|
+
1,
|
|
313
|
+
{"workflow_name": workflow_name, "error_type": type(error).__name__},
|
|
314
|
+
)
|
|
315
|
+
if start_time:
|
|
316
|
+
duration = time.time() - start_time
|
|
317
|
+
self._workflow_duration_histogram.record(
|
|
318
|
+
duration, {"workflow_name": workflow_name, "status": "failed"}
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def on_workflow_cancelled(
|
|
322
|
+
self, instance_id: str, workflow_name: str # noqa: ARG002
|
|
323
|
+
) -> None:
|
|
324
|
+
"""End workflow span with cancelled status."""
|
|
325
|
+
span = self._workflow_spans.pop(instance_id, None)
|
|
326
|
+
if span:
|
|
327
|
+
span.set_attribute("edda.workflow.cancelled", True)
|
|
328
|
+
span.set_status(Status(StatusCode.OK, "Cancelled"))
|
|
329
|
+
span.end()
|
|
330
|
+
|
|
331
|
+
self._workflow_start_times.pop(instance_id, None)
|
|
332
|
+
|
|
333
|
+
# =========================================================================
|
|
334
|
+
# Activity Hooks
|
|
335
|
+
# =========================================================================
|
|
336
|
+
|
|
337
|
+
async def on_activity_start(
|
|
338
|
+
self,
|
|
339
|
+
instance_id: str,
|
|
340
|
+
activity_id: str,
|
|
341
|
+
activity_name: str,
|
|
342
|
+
is_replaying: bool,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Start an activity span as child of workflow span."""
|
|
345
|
+
parent_span = self._workflow_spans.get(instance_id)
|
|
346
|
+
|
|
347
|
+
# Create activity span with parent context
|
|
348
|
+
if parent_span:
|
|
349
|
+
ctx = trace.set_span_in_context(parent_span)
|
|
350
|
+
span = self._tracer.start_span(
|
|
351
|
+
name=f"activity:{activity_name}",
|
|
352
|
+
context=ctx,
|
|
353
|
+
attributes={
|
|
354
|
+
"edda.activity.id": activity_id,
|
|
355
|
+
"edda.activity.name": activity_name,
|
|
356
|
+
"edda.activity.is_replaying": is_replaying,
|
|
357
|
+
"edda.workflow.instance_id": instance_id,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
# No parent workflow span (edge case)
|
|
362
|
+
span = self._tracer.start_span(
|
|
363
|
+
name=f"activity:{activity_name}",
|
|
364
|
+
attributes={
|
|
365
|
+
"edda.activity.id": activity_id,
|
|
366
|
+
"edda.activity.name": activity_name,
|
|
367
|
+
"edda.activity.is_replaying": is_replaying,
|
|
368
|
+
"edda.workflow.instance_id": instance_id,
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
key = f"{instance_id}:{activity_id}"
|
|
373
|
+
self._activity_spans[key] = span
|
|
374
|
+
self._activity_start_times[key] = time.time()
|
|
375
|
+
|
|
376
|
+
async def on_activity_complete(
|
|
377
|
+
self,
|
|
378
|
+
instance_id: str,
|
|
379
|
+
activity_id: str,
|
|
380
|
+
activity_name: str,
|
|
381
|
+
result: Any, # noqa: ARG002
|
|
382
|
+
cache_hit: bool,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""End activity span with success status."""
|
|
385
|
+
key = f"{instance_id}:{activity_id}"
|
|
386
|
+
span = self._activity_spans.pop(key, None)
|
|
387
|
+
if span:
|
|
388
|
+
span.set_attribute("edda.activity.cache_hit", cache_hit)
|
|
389
|
+
span.set_status(Status(StatusCode.OK))
|
|
390
|
+
span.end()
|
|
391
|
+
|
|
392
|
+
# Metrics
|
|
393
|
+
if self._enable_metrics:
|
|
394
|
+
if cache_hit:
|
|
395
|
+
self._activity_cache_hit_counter.add(1, {"activity_name": activity_name})
|
|
396
|
+
else:
|
|
397
|
+
self._activity_executed_counter.add(1, {"activity_name": activity_name})
|
|
398
|
+
start_time = self._activity_start_times.pop(key, None)
|
|
399
|
+
if start_time:
|
|
400
|
+
duration = time.time() - start_time
|
|
401
|
+
self._activity_duration_histogram.record(
|
|
402
|
+
duration, {"activity_name": activity_name}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
async def on_activity_failed(
|
|
406
|
+
self,
|
|
407
|
+
instance_id: str,
|
|
408
|
+
activity_id: str,
|
|
409
|
+
activity_name: str, # noqa: ARG002
|
|
410
|
+
error: Exception,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""End activity span with error status."""
|
|
413
|
+
key = f"{instance_id}:{activity_id}"
|
|
414
|
+
span = self._activity_spans.pop(key, None)
|
|
415
|
+
if span:
|
|
416
|
+
span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
417
|
+
span.record_exception(error)
|
|
418
|
+
span.end()
|
|
419
|
+
|
|
420
|
+
self._activity_start_times.pop(key, None)
|
|
421
|
+
|
|
422
|
+
async def on_activity_retry(
|
|
423
|
+
self,
|
|
424
|
+
instance_id: str,
|
|
425
|
+
activity_id: str,
|
|
426
|
+
activity_name: str, # noqa: ARG002
|
|
427
|
+
error: Exception,
|
|
428
|
+
attempt: int,
|
|
429
|
+
delay: float,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Record retry event on current activity span."""
|
|
432
|
+
key = f"{instance_id}:{activity_id}"
|
|
433
|
+
span = self._activity_spans.get(key)
|
|
434
|
+
if span:
|
|
435
|
+
span.add_event(
|
|
436
|
+
"retry",
|
|
437
|
+
attributes={
|
|
438
|
+
"edda.retry.attempt": attempt,
|
|
439
|
+
"edda.retry.delay_seconds": delay,
|
|
440
|
+
"edda.retry.error": str(error),
|
|
441
|
+
"edda.retry.error_type": type(error).__name__,
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# =========================================================================
|
|
446
|
+
# Event Hooks
|
|
447
|
+
# =========================================================================
|
|
448
|
+
|
|
449
|
+
async def on_event_sent(
|
|
450
|
+
self,
|
|
451
|
+
event_type: str,
|
|
452
|
+
event_source: str,
|
|
453
|
+
event_data: dict[str, Any], # noqa: ARG002
|
|
454
|
+
) -> None:
|
|
455
|
+
"""Record event sent as a short-lived span."""
|
|
456
|
+
with self._tracer.start_as_current_span(
|
|
457
|
+
name=f"event:send:{event_type}",
|
|
458
|
+
attributes={
|
|
459
|
+
"edda.event.type": event_type,
|
|
460
|
+
"edda.event.source": event_source,
|
|
461
|
+
},
|
|
462
|
+
) as span:
|
|
463
|
+
span.set_status(Status(StatusCode.OK))
|
|
464
|
+
|
|
465
|
+
async def on_event_received(
|
|
466
|
+
self,
|
|
467
|
+
instance_id: str,
|
|
468
|
+
event_type: str,
|
|
469
|
+
event_data: dict[str, Any], # noqa: ARG002
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Record event received as an event on workflow span."""
|
|
472
|
+
parent_span = self._workflow_spans.get(instance_id)
|
|
473
|
+
if parent_span:
|
|
474
|
+
parent_span.add_event(
|
|
475
|
+
"event_received",
|
|
476
|
+
attributes={
|
|
477
|
+
"edda.event.type": event_type,
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# =========================================================================
|
|
482
|
+
# Trace Context Propagation
|
|
483
|
+
# =========================================================================
|
|
484
|
+
|
|
485
|
+
def _extract_trace_context(self, data: dict[str, Any]) -> Context | None:
|
|
486
|
+
"""Extract W3C Trace Context from data dict."""
|
|
487
|
+
carrier: dict[str, str] = {}
|
|
488
|
+
|
|
489
|
+
# Check _trace_context nested dict (recommended)
|
|
490
|
+
if "_trace_context" in data:
|
|
491
|
+
tc = data["_trace_context"]
|
|
492
|
+
if isinstance(tc, dict):
|
|
493
|
+
carrier.update({k: v for k, v in tc.items() if k in ("traceparent", "tracestate")})
|
|
494
|
+
|
|
495
|
+
# Also check top-level keys
|
|
496
|
+
if "traceparent" in data:
|
|
497
|
+
carrier["traceparent"] = str(data["traceparent"])
|
|
498
|
+
if "tracestate" in data:
|
|
499
|
+
carrier["tracestate"] = str(data["tracestate"])
|
|
500
|
+
|
|
501
|
+
return self._propagator.extract(carrier) if carrier else None
|
|
502
|
+
|
|
503
|
+
def get_trace_context(self, instance_id: str) -> dict[str, str]:
|
|
504
|
+
"""
|
|
505
|
+
Get W3C Trace Context for a workflow instance.
|
|
506
|
+
|
|
507
|
+
Use this to propagate trace context to external services or CloudEvents.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
instance_id: Workflow instance ID
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
dict with 'traceparent' and optionally 'tracestate' keys
|
|
514
|
+
"""
|
|
515
|
+
carrier: dict[str, str] = {}
|
|
516
|
+
span = self._workflow_spans.get(instance_id)
|
|
517
|
+
if span:
|
|
518
|
+
ctx = trace.set_span_in_context(span)
|
|
519
|
+
self._propagator.inject(carrier, context=ctx)
|
|
520
|
+
return carrier
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# =============================================================================
|
|
524
|
+
# Trace Context Propagation Helpers
|
|
525
|
+
# =============================================================================
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def inject_trace_context(
|
|
529
|
+
hooks: OpenTelemetryHooks, instance_id: str, event_data: dict[str, Any]
|
|
530
|
+
) -> dict[str, Any]:
|
|
531
|
+
"""
|
|
532
|
+
Inject W3C Trace Context into event data for CloudEvents propagation.
|
|
533
|
+
|
|
534
|
+
Use this before calling send_event_transactional() to propagate trace
|
|
535
|
+
context across service boundaries.
|
|
536
|
+
|
|
537
|
+
Example::
|
|
538
|
+
|
|
539
|
+
from edda.integrations.opentelemetry import inject_trace_context
|
|
540
|
+
from edda.outbox.transactional import send_event_transactional
|
|
541
|
+
|
|
542
|
+
event_data = {"order_id": "ORD-123", "amount": 99.99}
|
|
543
|
+
event_data = inject_trace_context(hooks, ctx.instance_id, event_data)
|
|
544
|
+
await send_event_transactional(ctx, "payment.completed", "payment-service", event_data)
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
hooks: OpenTelemetryHooks instance
|
|
548
|
+
instance_id: Workflow instance ID
|
|
549
|
+
event_data: Event data dict to inject trace context into
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Updated event_data with _trace_context key
|
|
553
|
+
"""
|
|
554
|
+
trace_context = hooks.get_trace_context(instance_id)
|
|
555
|
+
if trace_context:
|
|
556
|
+
event_data["_trace_context"] = trace_context
|
|
557
|
+
return event_data
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def extract_trace_context(event_data: dict[str, Any]) -> Context | None:
|
|
561
|
+
"""
|
|
562
|
+
Extract W3C Trace Context from event data.
|
|
563
|
+
|
|
564
|
+
This is called automatically by OpenTelemetryHooks.on_workflow_start(),
|
|
565
|
+
but can also be used manually if needed.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
event_data: Event data dict containing _trace_context
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
OpenTelemetry Context or None if no trace context found
|
|
572
|
+
"""
|
|
573
|
+
if not _OPENTELEMETRY_AVAILABLE:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
if "_trace_context" in event_data:
|
|
577
|
+
propagator = TraceContextTextMapPropagator()
|
|
578
|
+
return propagator.extract(event_data["_trace_context"])
|
|
579
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edda-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Lightweight Durable Execution Framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2y/edda
|
|
6
6
|
Project-URL: Documentation, https://github.com/i2y/edda#readme
|
|
@@ -42,6 +42,10 @@ Provides-Extra: mcp
|
|
|
42
42
|
Requires-Dist: mcp>=1.22.0; extra == 'mcp'
|
|
43
43
|
Provides-Extra: mysql
|
|
44
44
|
Requires-Dist: aiomysql>=0.2.0; extra == 'mysql'
|
|
45
|
+
Provides-Extra: opentelemetry
|
|
46
|
+
Requires-Dist: opentelemetry-api>=1.20.0; extra == 'opentelemetry'
|
|
47
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.20.0; extra == 'opentelemetry'
|
|
48
|
+
Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'opentelemetry'
|
|
45
49
|
Provides-Extra: postgresql
|
|
46
50
|
Requires-Dist: asyncpg>=0.30.0; extra == 'postgresql'
|
|
47
51
|
Provides-Extra: server
|
|
@@ -91,6 +95,28 @@ Edda excels at orchestrating **long-running workflows** that must survive failur
|
|
|
91
95
|
- **🤖 AI Agent Workflows**: Orchestrate multi-step AI tasks (LLM calls, tool usage, long-running inference)
|
|
92
96
|
- **📡 Event-Driven Workflows**: React to external events with guaranteed delivery and automatic retry
|
|
93
97
|
|
|
98
|
+
### Business Process Automation
|
|
99
|
+
|
|
100
|
+
Edda's waiting functions make it ideal for time-based and event-driven business processes:
|
|
101
|
+
|
|
102
|
+
- **📧 User Onboarding**: Send reminders if users haven't completed setup after N days
|
|
103
|
+
- **🎁 Campaign Processing**: Evaluate conditions and notify winners after campaign ends
|
|
104
|
+
- **💳 Payment Reminders**: Send escalating reminders before payment deadlines
|
|
105
|
+
- **📦 Scheduled Notifications**: Shipping updates, subscription renewals, appointment reminders
|
|
106
|
+
|
|
107
|
+
**Waiting functions**:
|
|
108
|
+
- `wait_timer(duration_seconds)`: Wait for a relative duration
|
|
109
|
+
- `wait_until(until_time)`: Wait until an absolute datetime (e.g., campaign end date)
|
|
110
|
+
- `wait_event(event_type)`: Wait for external events (near real-time response)
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
@workflow
|
|
114
|
+
async def onboarding_reminder(ctx: WorkflowContext, user_id: str):
|
|
115
|
+
await wait_timer(ctx, duration_seconds=3*24*60*60) # Wait 3 days
|
|
116
|
+
if not await check_completed(ctx, user_id):
|
|
117
|
+
await send_reminder(ctx, user_id)
|
|
118
|
+
```
|
|
119
|
+
|
|
94
120
|
**Key benefit**: Workflows **never lose progress** - crashes and restarts are handled automatically through deterministic replay.
|
|
95
121
|
|
|
96
122
|
## Architecture
|
|
@@ -683,7 +709,7 @@ def process_payment(ctx: WorkflowContext, amount: float) -> dict:
|
|
|
683
709
|
@workflow
|
|
684
710
|
async def payment_workflow(ctx: WorkflowContext, order_id: str) -> dict:
|
|
685
711
|
# Workflows still use async (for deterministic replay)
|
|
686
|
-
result = await process_payment(ctx, 99.99
|
|
712
|
+
result = await process_payment(ctx, 99.99)
|
|
687
713
|
return result
|
|
688
714
|
```
|
|
689
715
|
|
|
@@ -722,13 +748,14 @@ if __name__ == "__main__":
|
|
|
722
748
|
|
|
723
749
|
### Auto-Generated Tools
|
|
724
750
|
|
|
725
|
-
Each `@durable_tool` automatically generates **
|
|
751
|
+
Each `@durable_tool` automatically generates **four MCP tools**:
|
|
726
752
|
|
|
727
753
|
1. **Main tool** (`process_order`): Starts the workflow, returns instance ID
|
|
728
|
-
2. **Status tool** (`process_order_status`): Checks workflow progress
|
|
754
|
+
2. **Status tool** (`process_order_status`): Checks workflow progress with completed activity count and suggested poll interval
|
|
729
755
|
3. **Result tool** (`process_order_result`): Gets final result when completed
|
|
756
|
+
4. **Cancel tool** (`process_order_cancel`): Cancels workflow if running or waiting, executes compensation handlers
|
|
730
757
|
|
|
731
|
-
This enables AI assistants to work with workflows that take minutes, hours, or even days to complete.
|
|
758
|
+
This enables AI assistants to work with workflows that take minutes, hours, or even days to complete, with full control over the workflow lifecycle.
|
|
732
759
|
|
|
733
760
|
### MCP Prompts
|
|
734
761
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
edda/__init__.py,sha256=gmJd0ooVbGNMOLlSj-r6rEt3IkY-FZYCFjhWjIltqlk,1657
|
|
2
2
|
edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
|
|
3
|
-
edda/app.py,sha256=
|
|
3
|
+
edda/app.py,sha256=8FKx9Kspbm5Fz-QDz4DgncNkkgdZkij209LHllWkRw4,38288
|
|
4
4
|
edda/compensation.py,sha256=CmnyJy4jAklVrtLJodNOcj6vxET6pdarxM1Yx2RHlL4,11898
|
|
5
5
|
edda/context.py,sha256=YZKBNtblRcaFqte1Y9t2cIP3JHzK-5Tu40x5i5FHtnU,17789
|
|
6
6
|
edda/events.py,sha256=KN06o-Umkwkg9-TwbN4jr1uBZrBrvVSc6m8mOlQGXkA,18043
|
|
7
7
|
edda/exceptions.py,sha256=-ntBLGpVQgPFG5N1o8m_7weejAYkNrUdxTkOP38vsHk,1766
|
|
8
|
-
edda/hooks.py,sha256=
|
|
8
|
+
edda/hooks.py,sha256=HUZ6FTM__DZjwuomDfTDEroQ3mugEPuJHcGm7CTQNvg,8193
|
|
9
9
|
edda/locking.py,sha256=l3YM7zdERizw27jQXfLN7EmcMcrJSVzd7LD8hhsXvIM,11003
|
|
10
10
|
edda/pydantic_utils.py,sha256=dGVPNrrttDeq1k233PopCtjORYjZitsgASPfPnO6R10,9056
|
|
11
11
|
edda/replay.py,sha256=5RIRd0q2ZrH9iiiy35eOUii2cipYg9dlua56OAXvIk4,32499
|
|
@@ -14,8 +14,10 @@ edda/workflow.py,sha256=daSppYAzgXkjY_9-HS93Zi7_tPR6srmchxY5YfwgU-4,7239
|
|
|
14
14
|
edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
|
|
15
15
|
edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
|
|
16
16
|
edda/integrations/mcp/__init__.py,sha256=YK-8m0DIdP-RSqewlIX7xnWU7TD3NioCiW2_aZSgnn8,1232
|
|
17
|
-
edda/integrations/mcp/decorators.py,sha256=
|
|
18
|
-
edda/integrations/mcp/server.py,sha256=
|
|
17
|
+
edda/integrations/mcp/decorators.py,sha256=31SmbDwmHEGvUNa3aaatW91hBkpnS5iN9uy47dID3J4,10037
|
|
18
|
+
edda/integrations/mcp/server.py,sha256=Q5r4AbMn-9gBcy2CZocbgW7O0fn7Qb4e9CBJa1FEmzU,14507
|
|
19
|
+
edda/integrations/opentelemetry/__init__.py,sha256=x1_PyyygGDW-rxQTwoIrGzyjKErXHOOKdquFAMlCOAo,906
|
|
20
|
+
edda/integrations/opentelemetry/hooks.py,sha256=FBYnSZBh8_0vw9M1E2AbJrx1cTTsKeiHf5wspr0UnzU,21288
|
|
19
21
|
edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
|
|
20
22
|
edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
|
|
21
23
|
edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
|
|
@@ -33,8 +35,8 @@ edda/viewer_ui/data_service.py,sha256=mXV6bL6REa_UKsk8xMGBIFbsbLpIxe91lX3wgn-FOj
|
|
|
33
35
|
edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
|
|
34
36
|
edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
|
|
35
37
|
edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
|
|
36
|
-
edda_framework-0.
|
|
37
|
-
edda_framework-0.
|
|
38
|
-
edda_framework-0.
|
|
39
|
-
edda_framework-0.
|
|
40
|
-
edda_framework-0.
|
|
38
|
+
edda_framework-0.6.0.dist-info/METADATA,sha256=sxR8GtZNOzswVHIh8n_uAojtOrUEn_mXbT9Li5IvTnk,31702
|
|
39
|
+
edda_framework-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
40
|
+
edda_framework-0.6.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
|
|
41
|
+
edda_framework-0.6.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
|
|
42
|
+
edda_framework-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|