puffinflow 2.dev0__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.
- puffinflow/__init__.py +132 -0
- puffinflow/core/__init__.py +110 -0
- puffinflow/core/agent/__init__.py +320 -0
- puffinflow/core/agent/base.py +1635 -0
- puffinflow/core/agent/checkpoint.py +50 -0
- puffinflow/core/agent/context.py +521 -0
- puffinflow/core/agent/decorators/__init__.py +90 -0
- puffinflow/core/agent/decorators/builder.py +454 -0
- puffinflow/core/agent/decorators/flexible.py +714 -0
- puffinflow/core/agent/decorators/inspection.py +144 -0
- puffinflow/core/agent/dependencies.py +57 -0
- puffinflow/core/agent/scheduling/__init__.py +21 -0
- puffinflow/core/agent/scheduling/builder.py +160 -0
- puffinflow/core/agent/scheduling/exceptions.py +35 -0
- puffinflow/core/agent/scheduling/inputs.py +137 -0
- puffinflow/core/agent/scheduling/parser.py +209 -0
- puffinflow/core/agent/scheduling/scheduler.py +413 -0
- puffinflow/core/agent/state.py +141 -0
- puffinflow/core/config.py +62 -0
- puffinflow/core/coordination/__init__.py +137 -0
- puffinflow/core/coordination/agent_group.py +359 -0
- puffinflow/core/coordination/agent_pool.py +629 -0
- puffinflow/core/coordination/agent_team.py +577 -0
- puffinflow/core/coordination/coordinator.py +720 -0
- puffinflow/core/coordination/deadlock.py +1759 -0
- puffinflow/core/coordination/fluent_api.py +421 -0
- puffinflow/core/coordination/primitives.py +478 -0
- puffinflow/core/coordination/rate_limiter.py +520 -0
- puffinflow/core/observability/__init__.py +47 -0
- puffinflow/core/observability/agent.py +139 -0
- puffinflow/core/observability/alerting.py +73 -0
- puffinflow/core/observability/config.py +127 -0
- puffinflow/core/observability/context.py +88 -0
- puffinflow/core/observability/core.py +147 -0
- puffinflow/core/observability/decorators.py +105 -0
- puffinflow/core/observability/events.py +71 -0
- puffinflow/core/observability/interfaces.py +196 -0
- puffinflow/core/observability/metrics.py +137 -0
- puffinflow/core/observability/tracing.py +209 -0
- puffinflow/core/reliability/__init__.py +27 -0
- puffinflow/core/reliability/bulkhead.py +96 -0
- puffinflow/core/reliability/circuit_breaker.py +149 -0
- puffinflow/core/reliability/leak_detector.py +122 -0
- puffinflow/core/resources/__init__.py +77 -0
- puffinflow/core/resources/allocation.py +790 -0
- puffinflow/core/resources/pool.py +645 -0
- puffinflow/core/resources/quotas.py +567 -0
- puffinflow/core/resources/requirements.py +217 -0
- puffinflow/version.py +21 -0
- puffinflow-2.dev0.dist-info/METADATA +334 -0
- puffinflow-2.dev0.dist-info/RECORD +55 -0
- puffinflow-2.dev0.dist-info/WHEEL +5 -0
- puffinflow-2.dev0.dist-info/entry_points.txt +3 -0
- puffinflow-2.dev0.dist-info/licenses/LICENSE +21 -0
- puffinflow-2.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .config import AlertingConfig
|
|
9
|
+
from .interfaces import AlertingProvider, AlertSeverity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Alert:
|
|
14
|
+
"""Alert data structure"""
|
|
15
|
+
|
|
16
|
+
message: str
|
|
17
|
+
severity: AlertSeverity
|
|
18
|
+
attributes: dict[str, Any]
|
|
19
|
+
timestamp: Optional[datetime] = None
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.timestamp is None:
|
|
23
|
+
self.timestamp = datetime.now()
|
|
24
|
+
else:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"message": self.message,
|
|
30
|
+
"severity": self.severity.value,
|
|
31
|
+
"attributes": self.attributes,
|
|
32
|
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebhookAlerting(AlertingProvider):
|
|
37
|
+
"""Webhook-based alerting"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: AlertingConfig):
|
|
40
|
+
self.config = config
|
|
41
|
+
|
|
42
|
+
async def send_alert(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
severity: AlertSeverity,
|
|
46
|
+
attributes: Optional[dict[str, Any]] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Send alert via webhooks"""
|
|
49
|
+
if not self.config.enabled or not self.config.webhook_urls:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
alert = Alert(message, severity, attributes or {})
|
|
53
|
+
payload = {"alert": alert.to_dict()}
|
|
54
|
+
|
|
55
|
+
tasks = []
|
|
56
|
+
for webhook_url in self.config.webhook_urls:
|
|
57
|
+
task = asyncio.create_task(self._send_webhook(webhook_url, payload))
|
|
58
|
+
tasks.append(task)
|
|
59
|
+
|
|
60
|
+
if tasks:
|
|
61
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
62
|
+
|
|
63
|
+
async def _send_webhook(self, url: str, payload: dict[str, Any]) -> None:
|
|
64
|
+
"""Send single webhook"""
|
|
65
|
+
try:
|
|
66
|
+
async with (
|
|
67
|
+
aiohttp.ClientSession() as session,
|
|
68
|
+
session.post(url, json=payload, timeout=30) as response,
|
|
69
|
+
):
|
|
70
|
+
if response.status >= 400:
|
|
71
|
+
print(f"Webhook failed: {response.status}")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
print(f"Failed to send webhook to {url}: {e}")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class TracingConfig:
|
|
8
|
+
"""Tracing configuration"""
|
|
9
|
+
|
|
10
|
+
enabled: bool = True
|
|
11
|
+
service_name: str = "puffinflow"
|
|
12
|
+
service_version: str = "1.0.0"
|
|
13
|
+
sample_rate: float = 1.0
|
|
14
|
+
otlp_endpoint: Optional[str] = None
|
|
15
|
+
jaeger_endpoint: Optional[str] = None
|
|
16
|
+
console_enabled: bool = False
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_env(cls) -> "TracingConfig":
|
|
20
|
+
return cls(
|
|
21
|
+
enabled=os.getenv("TRACING_ENABLED", "true").lower() == "true",
|
|
22
|
+
service_name=os.getenv("SERVICE_NAME", "puffinflow"),
|
|
23
|
+
service_version=os.getenv("SERVICE_VERSION", "1.0.0"),
|
|
24
|
+
sample_rate=float(os.getenv("TRACE_SAMPLE_RATE", "1.0")),
|
|
25
|
+
otlp_endpoint=os.getenv("OTLP_ENDPOINT"),
|
|
26
|
+
jaeger_endpoint=os.getenv("JAEGER_ENDPOINT"),
|
|
27
|
+
console_enabled=os.getenv("TRACE_CONSOLE", "false").lower() == "true",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class MetricsConfig:
|
|
33
|
+
"""Metrics configuration"""
|
|
34
|
+
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
namespace: str = "puffinflow"
|
|
37
|
+
prometheus_port: int = 9090
|
|
38
|
+
prometheus_path: str = "/metrics"
|
|
39
|
+
collection_interval: float = 15.0
|
|
40
|
+
cardinality_limit: int = 10000
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_env(cls) -> "MetricsConfig":
|
|
44
|
+
return cls(
|
|
45
|
+
enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true",
|
|
46
|
+
namespace=os.getenv("METRICS_NAMESPACE", "puffinflow"),
|
|
47
|
+
prometheus_port=int(os.getenv("METRICS_PORT", "9090")),
|
|
48
|
+
prometheus_path=os.getenv("METRICS_PATH", "/metrics"),
|
|
49
|
+
collection_interval=float(os.getenv("METRICS_INTERVAL", "15.0")),
|
|
50
|
+
cardinality_limit=int(os.getenv("METRICS_CARDINALITY_LIMIT", "10000")),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AlertingConfig:
|
|
56
|
+
"""Alerting configuration"""
|
|
57
|
+
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
evaluation_interval: float = 30.0
|
|
60
|
+
webhook_urls: list[str] = field(default_factory=list)
|
|
61
|
+
email_recipients: list[str] = field(default_factory=list)
|
|
62
|
+
slack_webhook_url: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_env(cls) -> "AlertingConfig":
|
|
66
|
+
webhook_urls = (
|
|
67
|
+
os.getenv("ALERT_WEBHOOK_URLS", "").split(",")
|
|
68
|
+
if os.getenv("ALERT_WEBHOOK_URLS")
|
|
69
|
+
else []
|
|
70
|
+
)
|
|
71
|
+
email_recipients = (
|
|
72
|
+
os.getenv("ALERT_EMAIL_RECIPIENTS", "").split(",")
|
|
73
|
+
if os.getenv("ALERT_EMAIL_RECIPIENTS")
|
|
74
|
+
else []
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return cls(
|
|
78
|
+
enabled=os.getenv("ALERTING_ENABLED", "true").lower() == "true",
|
|
79
|
+
evaluation_interval=float(os.getenv("ALERT_EVALUATION_INTERVAL", "30.0")),
|
|
80
|
+
webhook_urls=[url.strip() for url in webhook_urls if url.strip()],
|
|
81
|
+
email_recipients=[
|
|
82
|
+
email.strip() for email in email_recipients if email.strip()
|
|
83
|
+
],
|
|
84
|
+
slack_webhook_url=os.getenv("ALERT_SLACK_WEBHOOK"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class EventsConfig:
|
|
90
|
+
"""Events configuration"""
|
|
91
|
+
|
|
92
|
+
enabled: bool = True
|
|
93
|
+
buffer_size: int = 1000
|
|
94
|
+
batch_size: int = 100
|
|
95
|
+
flush_interval: float = 5.0
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_env(cls) -> "EventsConfig":
|
|
99
|
+
return cls(
|
|
100
|
+
enabled=os.getenv("EVENTS_ENABLED", "true").lower() == "true",
|
|
101
|
+
buffer_size=int(os.getenv("EVENT_BUFFER_SIZE", "1000")),
|
|
102
|
+
batch_size=int(os.getenv("EVENT_BATCH_SIZE", "100")),
|
|
103
|
+
flush_interval=float(os.getenv("EVENT_FLUSH_INTERVAL", "5.0")),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ObservabilityConfig:
|
|
109
|
+
"""Complete observability configuration"""
|
|
110
|
+
|
|
111
|
+
enabled: bool = True
|
|
112
|
+
environment: str = "development"
|
|
113
|
+
tracing: TracingConfig = field(default_factory=TracingConfig)
|
|
114
|
+
metrics: MetricsConfig = field(default_factory=MetricsConfig)
|
|
115
|
+
alerting: AlertingConfig = field(default_factory=AlertingConfig)
|
|
116
|
+
events: EventsConfig = field(default_factory=EventsConfig)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_env(cls) -> "ObservabilityConfig":
|
|
120
|
+
return cls(
|
|
121
|
+
enabled=os.getenv("OBSERVABILITY_ENABLED", "true").lower() == "true",
|
|
122
|
+
environment=os.getenv("ENVIRONMENT", "development"),
|
|
123
|
+
tracing=TracingConfig.from_env(),
|
|
124
|
+
metrics=MetricsConfig.from_env(),
|
|
125
|
+
alerting=AlertingConfig.from_env(),
|
|
126
|
+
events=EventsConfig.from_env(),
|
|
127
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Generator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from ..agent.context import Context
|
|
8
|
+
from .core import ObservabilityManager
|
|
9
|
+
from .interfaces import ObservabilityEvent, SpanType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ObservableContext(Context):
|
|
13
|
+
"""Context with observability integration"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
shared_state: dict[str, Any],
|
|
18
|
+
observability: Optional[ObservabilityManager] = None,
|
|
19
|
+
):
|
|
20
|
+
super().__init__(shared_state)
|
|
21
|
+
self._observability = observability
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def trace(self, name: str, **attributes: Any) -> Generator[Any, None, None]:
|
|
25
|
+
"""Create trace span with context"""
|
|
26
|
+
if not self._observability or not self._observability.tracing:
|
|
27
|
+
yield None
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
# Add context attributes
|
|
31
|
+
context_attrs = {
|
|
32
|
+
"workflow_id": self.get_variable("workflow_id"),
|
|
33
|
+
"agent_name": self.get_variable("agent_name"),
|
|
34
|
+
"state_name": self.get_variable("current_state"),
|
|
35
|
+
**attributes,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
with self._observability.tracing.span(
|
|
39
|
+
name, SpanType.BUSINESS, **context_attrs
|
|
40
|
+
) as span:
|
|
41
|
+
yield span
|
|
42
|
+
|
|
43
|
+
def metric(self, name: str, value: float, **labels: Any) -> None:
|
|
44
|
+
"""Record metric with context"""
|
|
45
|
+
if not self._observability or not self._observability.metrics:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
context_labels = {
|
|
49
|
+
"workflow_id": self.get_variable("workflow_id", "unknown"),
|
|
50
|
+
"agent_name": self.get_variable("agent_name", "unknown"),
|
|
51
|
+
**labels,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
histogram = self._observability.histogram(
|
|
55
|
+
name, labels=list(context_labels.keys())
|
|
56
|
+
)
|
|
57
|
+
if histogram:
|
|
58
|
+
histogram.record(value, **context_labels)
|
|
59
|
+
|
|
60
|
+
def log(self, level: str, message: str, **kwargs: Any) -> None:
|
|
61
|
+
"""Log with observability"""
|
|
62
|
+
if not self._observability or not self._observability.events:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
event = ObservabilityEvent(
|
|
66
|
+
timestamp=datetime.fromtimestamp(time.time()),
|
|
67
|
+
event_type="log",
|
|
68
|
+
source="context",
|
|
69
|
+
level=level.upper(),
|
|
70
|
+
message=message,
|
|
71
|
+
attributes={
|
|
72
|
+
"workflow_id": self.get_variable("workflow_id"),
|
|
73
|
+
"agent_name": self.get_variable("agent_name"),
|
|
74
|
+
"state_name": self.get_variable("current_state"),
|
|
75
|
+
**kwargs,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
import asyncio
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
loop = asyncio.get_event_loop()
|
|
83
|
+
# Create task but don't store reference as it's fire-and-forget
|
|
84
|
+
task = loop.create_task(self._observability.events.process_event(event))
|
|
85
|
+
if task:
|
|
86
|
+
task.add_done_callback(lambda t: None) # Prevent warnings
|
|
87
|
+
except RuntimeError:
|
|
88
|
+
pass
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .alerting import WebhookAlerting
|
|
7
|
+
from .config import ObservabilityConfig
|
|
8
|
+
from .events import BufferedEventProcessor
|
|
9
|
+
from .interfaces import (
|
|
10
|
+
AlertingProvider,
|
|
11
|
+
EventProcessor,
|
|
12
|
+
MetricsProvider,
|
|
13
|
+
TracingProvider,
|
|
14
|
+
)
|
|
15
|
+
from .metrics import PrometheusMetricsProvider
|
|
16
|
+
from .tracing import OpenTelemetryTracingProvider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObservabilityManager:
|
|
20
|
+
"""Main observability coordinator"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Optional[ObservabilityConfig] = None):
|
|
23
|
+
self.config = config or ObservabilityConfig()
|
|
24
|
+
self._initialized = False
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
# Initialize providers
|
|
28
|
+
self._tracing: Optional[TracingProvider] = None
|
|
29
|
+
self._metrics: Optional[MetricsProvider] = None
|
|
30
|
+
self._alerting: Optional[AlertingProvider] = None
|
|
31
|
+
self._events: Optional[EventProcessor] = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def tracing(self) -> Optional[TracingProvider]:
|
|
35
|
+
return self._tracing
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def metrics(self) -> Optional[MetricsProvider]:
|
|
39
|
+
return self._metrics
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def alerting(self) -> Optional[AlertingProvider]:
|
|
43
|
+
return self._alerting
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def events(self) -> Optional[EventProcessor]:
|
|
47
|
+
return self._events
|
|
48
|
+
|
|
49
|
+
async def initialize(self) -> None:
|
|
50
|
+
"""Initialize all components"""
|
|
51
|
+
with self._lock:
|
|
52
|
+
if self._initialized:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Initialize tracing
|
|
56
|
+
if self.config.tracing.enabled:
|
|
57
|
+
self._tracing = OpenTelemetryTracingProvider(self.config.tracing)
|
|
58
|
+
|
|
59
|
+
# Initialize metrics
|
|
60
|
+
if self.config.metrics.enabled:
|
|
61
|
+
self._metrics = PrometheusMetricsProvider(self.config.metrics)
|
|
62
|
+
|
|
63
|
+
# Initialize alerting
|
|
64
|
+
if self.config.alerting.enabled:
|
|
65
|
+
self._alerting = WebhookAlerting(self.config.alerting)
|
|
66
|
+
|
|
67
|
+
# Initialize events
|
|
68
|
+
if self.config.events.enabled:
|
|
69
|
+
self._events = BufferedEventProcessor(self.config.events)
|
|
70
|
+
await self._events.initialize()
|
|
71
|
+
|
|
72
|
+
self._initialized = True
|
|
73
|
+
|
|
74
|
+
async def shutdown(self) -> None:
|
|
75
|
+
"""Shutdown all components"""
|
|
76
|
+
if self._events and hasattr(self._events, "shutdown"):
|
|
77
|
+
await self._events.shutdown()
|
|
78
|
+
self._initialized = False
|
|
79
|
+
|
|
80
|
+
# Convenience methods
|
|
81
|
+
@contextmanager
|
|
82
|
+
def trace(self, name: str, **attributes: Any) -> Iterator[Any]:
|
|
83
|
+
"""Create trace span"""
|
|
84
|
+
if self._tracing and hasattr(self._tracing, "span"):
|
|
85
|
+
with self._tracing.span(name, **attributes) as span:
|
|
86
|
+
yield span
|
|
87
|
+
else:
|
|
88
|
+
yield None
|
|
89
|
+
|
|
90
|
+
def counter(
|
|
91
|
+
self, name: str, description: str = "", labels: Optional[list[str]] = None
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""Create counter metric"""
|
|
94
|
+
if self._metrics:
|
|
95
|
+
return self._metrics.counter(name, description, labels)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def gauge(
|
|
99
|
+
self, name: str, description: str = "", labels: Optional[list[str]] = None
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Create gauge metric"""
|
|
102
|
+
if self._metrics:
|
|
103
|
+
return self._metrics.gauge(name, description, labels)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def histogram(
|
|
107
|
+
self, name: str, description: str = "", labels: Optional[list[str]] = None
|
|
108
|
+
) -> Any:
|
|
109
|
+
"""Create histogram metric"""
|
|
110
|
+
if self._metrics:
|
|
111
|
+
return self._metrics.histogram(name, description, labels)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
async def alert(
|
|
115
|
+
self, message: str, severity: str = "warning", **attributes: Any
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Send alert"""
|
|
118
|
+
if self._alerting:
|
|
119
|
+
from .interfaces import AlertSeverity
|
|
120
|
+
|
|
121
|
+
sev = AlertSeverity(severity.lower())
|
|
122
|
+
await self._alerting.send_alert(message, sev, attributes)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Global instance management
|
|
126
|
+
_global_observability: Optional[ObservabilityManager] = None
|
|
127
|
+
_lock = threading.Lock()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_observability() -> ObservabilityManager:
|
|
131
|
+
"""Get global observability instance"""
|
|
132
|
+
global _global_observability
|
|
133
|
+
with _lock:
|
|
134
|
+
if _global_observability is None:
|
|
135
|
+
_global_observability = ObservabilityManager()
|
|
136
|
+
return _global_observability
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def setup_observability(
|
|
140
|
+
config: Optional[ObservabilityConfig] = None,
|
|
141
|
+
) -> ObservabilityManager:
|
|
142
|
+
"""Setup and initialize observability"""
|
|
143
|
+
global _global_observability
|
|
144
|
+
with _lock:
|
|
145
|
+
_global_observability = ObservabilityManager(config)
|
|
146
|
+
await _global_observability.initialize()
|
|
147
|
+
return _global_observability
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from .core import get_observability
|
|
7
|
+
from .interfaces import SpanType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def observe(
|
|
11
|
+
name: Optional[str] = None,
|
|
12
|
+
span_type: SpanType = SpanType.BUSINESS,
|
|
13
|
+
**span_attributes: Any,
|
|
14
|
+
) -> Callable[[Callable], Callable]:
|
|
15
|
+
"""Observability decorator"""
|
|
16
|
+
|
|
17
|
+
def decorator(func: Callable) -> Callable:
|
|
18
|
+
@functools.wraps(func)
|
|
19
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
20
|
+
observability = get_observability()
|
|
21
|
+
operation_name = name or f"{func.__module__}.{func.__name__}"
|
|
22
|
+
|
|
23
|
+
if observability.tracing and hasattr(observability.tracing, "span"):
|
|
24
|
+
with observability.tracing.span(
|
|
25
|
+
operation_name, span_type, function=func.__name__, **span_attributes
|
|
26
|
+
) as span:
|
|
27
|
+
start_time = time.time()
|
|
28
|
+
try:
|
|
29
|
+
result = await func(*args, **kwargs)
|
|
30
|
+
duration = time.time() - start_time
|
|
31
|
+
|
|
32
|
+
if span:
|
|
33
|
+
span.set_attribute("function.duration", duration)
|
|
34
|
+
span.set_status("ok")
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
except Exception as e:
|
|
38
|
+
if span:
|
|
39
|
+
span.record_exception(e)
|
|
40
|
+
raise
|
|
41
|
+
else:
|
|
42
|
+
return await func(*args, **kwargs)
|
|
43
|
+
|
|
44
|
+
@functools.wraps(func)
|
|
45
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
46
|
+
observability = get_observability()
|
|
47
|
+
operation_name = name or f"{func.__module__}.{func.__name__}"
|
|
48
|
+
|
|
49
|
+
if observability.tracing and hasattr(observability.tracing, "span"):
|
|
50
|
+
with observability.tracing.span(
|
|
51
|
+
operation_name, span_type, **span_attributes
|
|
52
|
+
) as span:
|
|
53
|
+
try:
|
|
54
|
+
result = func(*args, **kwargs)
|
|
55
|
+
if span:
|
|
56
|
+
span.set_status("ok")
|
|
57
|
+
return result
|
|
58
|
+
except Exception as e:
|
|
59
|
+
if span:
|
|
60
|
+
span.record_exception(e)
|
|
61
|
+
raise
|
|
62
|
+
else:
|
|
63
|
+
return func(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
|
66
|
+
|
|
67
|
+
return decorator
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def trace_state(
|
|
71
|
+
span_type: SpanType = SpanType.STATE, **span_attributes: Any
|
|
72
|
+
) -> Callable[[Callable], Callable]:
|
|
73
|
+
"""Decorator for tracing PuffinFlow states"""
|
|
74
|
+
|
|
75
|
+
def decorator(func: Callable) -> Callable:
|
|
76
|
+
@functools.wraps(func)
|
|
77
|
+
async def wrapper(context: Any, *args: Any, **kwargs: Any) -> Any:
|
|
78
|
+
observability = get_observability()
|
|
79
|
+
|
|
80
|
+
if observability.tracing and hasattr(observability.tracing, "span"):
|
|
81
|
+
attrs = {
|
|
82
|
+
"state.name": func.__name__,
|
|
83
|
+
"workflow.id": context.get_variable("workflow_id"),
|
|
84
|
+
"agent.name": context.get_variable("agent_name"),
|
|
85
|
+
**span_attributes,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
with observability.tracing.span(
|
|
89
|
+
f"state.{func.__name__}", span_type, **attrs
|
|
90
|
+
) as span:
|
|
91
|
+
try:
|
|
92
|
+
result = await func(context, *args, **kwargs)
|
|
93
|
+
if span:
|
|
94
|
+
span.set_status("ok")
|
|
95
|
+
return result
|
|
96
|
+
except Exception as e:
|
|
97
|
+
if span:
|
|
98
|
+
span.record_exception(e)
|
|
99
|
+
raise
|
|
100
|
+
else:
|
|
101
|
+
return await func(context, *args, **kwargs)
|
|
102
|
+
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
return decorator
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from .config import EventsConfig
|
|
7
|
+
from .interfaces import EventProcessor, ObservabilityEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BufferedEventProcessor(EventProcessor):
|
|
11
|
+
"""Buffered event processor"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config: EventsConfig):
|
|
14
|
+
self.config = config
|
|
15
|
+
self.buffer: Any = deque(maxlen=config.buffer_size)
|
|
16
|
+
self.subscribers: list[Callable[[ObservabilityEvent], None]] = []
|
|
17
|
+
self._task: Optional[Any] = None
|
|
18
|
+
self._shutdown = False
|
|
19
|
+
|
|
20
|
+
async def initialize(self) -> None:
|
|
21
|
+
"""Initialize event processor"""
|
|
22
|
+
if self.config.enabled:
|
|
23
|
+
self._task = asyncio.create_task(self._process_loop())
|
|
24
|
+
|
|
25
|
+
async def shutdown(self) -> None:
|
|
26
|
+
"""Shutdown event processor"""
|
|
27
|
+
self._shutdown = True
|
|
28
|
+
if self._task:
|
|
29
|
+
self._task.cancel()
|
|
30
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
31
|
+
await self._task
|
|
32
|
+
|
|
33
|
+
async def process_event(self, event: ObservabilityEvent) -> None:
|
|
34
|
+
"""Add event to buffer"""
|
|
35
|
+
if self.config.enabled:
|
|
36
|
+
self.buffer.append(event)
|
|
37
|
+
|
|
38
|
+
def subscribe(self, callback: Callable[[ObservabilityEvent], None]) -> None:
|
|
39
|
+
"""Subscribe to events"""
|
|
40
|
+
self.subscribers.append(callback)
|
|
41
|
+
|
|
42
|
+
async def _process_loop(self) -> None:
|
|
43
|
+
"""Process events from buffer"""
|
|
44
|
+
while not self._shutdown:
|
|
45
|
+
try:
|
|
46
|
+
events_to_process = []
|
|
47
|
+
|
|
48
|
+
# Collect batch
|
|
49
|
+
for _ in range(min(self.config.batch_size, len(self.buffer))):
|
|
50
|
+
if self.buffer:
|
|
51
|
+
events_to_process.append(self.buffer.popleft())
|
|
52
|
+
|
|
53
|
+
# Process events
|
|
54
|
+
for event in events_to_process:
|
|
55
|
+
for subscriber in self.subscribers:
|
|
56
|
+
try:
|
|
57
|
+
if asyncio.iscoroutinefunction(subscriber):
|
|
58
|
+
await subscriber(event)
|
|
59
|
+
else:
|
|
60
|
+
subscriber(event)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"Event processing error: {e}")
|
|
63
|
+
|
|
64
|
+
# Wait for next batch
|
|
65
|
+
await asyncio.sleep(self.config.flush_interval)
|
|
66
|
+
|
|
67
|
+
except asyncio.CancelledError:
|
|
68
|
+
break
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"Event processing loop error: {e}")
|
|
71
|
+
await asyncio.sleep(1)
|