Skip to content

Commit 732c81d

Browse files
author
loong-solvable
committed
feat: add optional OpenTelemetry tracing
1 parent d0b6016 commit 732c81d

10 files changed

Lines changed: 234 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ httpx = "*"
7373
requests = "*"
7474
mcp = "*"
7575
schedule = "*"
76+
opentelemetry-api = "*"
77+
opentelemetry-sdk = "*"
78+
opentelemetry-exporter-otlp-proto-http = "*"
7679

7780
[tool.poetry.scripts]
7881
swarms = "swarms.cli.main:main"
@@ -116,4 +119,3 @@ exclude = '''
116119
| docs
117120
)/
118121
'''
119-

swarms/structs/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
handle_transforms,
9797
)
9898
from swarms.telemetry.main import log_agent_data
99+
from swarms.telemetry.otel import trace_method
99100
from swarms.tools.base_tool import BaseTool
100101
from swarms.tools.handoffs_tool import handoff_task
101102
from swarms.tools.handoffs_tool_schema import get_handoff_tool_schema
@@ -4687,6 +4688,7 @@ def load_full_skill(self, skill_name: str) -> Optional[str]:
46874688
)
46884689
return None
46894690

4691+
@trace_method("swarms.agent.run")
46904692
def run(
46914693
self,
46924694
task: Optional[Union[str, Any]] = None,

swarms/structs/agent_rearrange.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from swarms.structs.multi_agent_exec import run_agents_concurrently
1010
from swarms.structs.swarm_id import swarm_id
1111
from swarms.telemetry.main import log_agent_data
12+
from swarms.telemetry.otel import trace_method
1213
from swarms.utils.any_to_str import any_to_str
1314
from swarms.utils.history_output_formatter import (
1415
history_output_formatter,
@@ -739,6 +740,7 @@ def _catch_error(self, e: Exception):
739740

740741
raise e
741742

743+
@trace_method("swarms.agent_rearrange.run")
742744
def run(
743745
self,
744746
task: str = None,

swarms/structs/concurrent_workflow.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from swarms.structs.agent import Agent
99
from swarms.structs.conversation import Conversation
1010
from swarms.structs.swarm_id import swarm_id
11+
from swarms.telemetry.otel import trace_method
1112
from swarms.utils.formatter import formatter
1213
from swarms.utils.get_cpu_cores import get_cpu_cores
1314
from swarms.utils.history_output_formatter import (
@@ -529,6 +530,7 @@ def cleanup(self):
529530
except Exception as e:
530531
logger.error(f"Cleanup failed: {str(e)}")
531532

533+
@trace_method("swarms.concurrent_workflow.run")
532534
def run(
533535
self,
534536
task: str,

swarms/structs/mixture_of_agents.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from swarms.structs.conversation import Conversation
99
from swarms.structs.ma_utils import list_all_agents
1010
from swarms.structs.multi_agent_exec import run_agents_concurrently
11+
from swarms.telemetry.otel import trace_method
1112
from swarms.utils.history_output_formatter import (
1213
history_output_formatter,
1314
)
@@ -194,6 +195,7 @@ def _run(
194195
conversation=self.conversation, type=self.output_type
195196
)
196197

198+
@trace_method("swarms.mixture_of_agents.run")
197199
def run(
198200
self,
199201
task: str,

swarms/structs/sequential_workflow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from swarms.structs.agent import Agent
1212
from swarms.structs.agent_rearrange import AgentRearrange
13+
from swarms.telemetry.otel import trace_method
1314
from swarms.utils.loguru_logger import initialize_logger
1415
from swarms.utils.output_types import OutputType
1516
from swarms.utils.swarm_autosave import get_swarm_workspace_dir
@@ -256,6 +257,7 @@ def _run_drift_detection(
256257
result = self.agent_rearrange.run(**run_kwargs)
257258
return result
258259

260+
@trace_method("swarms.sequential_workflow.run")
259261
def run(
260262
self,
261263
task: str,
@@ -444,6 +446,7 @@ def run_batched(self, tasks: List[str]) -> List[str]:
444446
)
445447
raise
446448

449+
@trace_method("swarms.sequential_workflow.run_async")
447450
async def run_async(self, task: str) -> str:
448451
"""
449452
Executes the specified task through the agents in the dynamically constructed flow asynchronously.

swarms/structs/swarm_router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from swarms.structs.planner_worker_swarm import PlannerWorkerSwarm
3636
from swarms.structs.round_robin import RoundRobinSwarm
3737
from swarms.structs.sequential_workflow import SequentialWorkflow
38+
from swarms.telemetry.otel import trace_method
3839
from swarms.utils.generate_keys import generate_api_key
3940
from swarms.utils.loguru_logger import initialize_logger
4041
from swarms.utils.output_types import OutputType
@@ -823,6 +824,7 @@ def _run(
823824
)
824825
raise e
825826

827+
@trace_method("swarms.swarm_router.run")
826828
def run(
827829
self,
828830
task: Optional[str] = None,

swarms/telemetry/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@
44
get_comprehensive_system_info,
55
log_agent_data,
66
)
7+
from swarms.telemetry.otel import (
8+
otel_span,
9+
setup_otel,
10+
trace_method,
11+
)
712

813
__all__ = [
914
"generate_user_id",
1015
"get_machine_id",
1116
"get_comprehensive_system_info",
1217
"log_agent_data",
18+
"otel_span",
19+
"setup_otel",
20+
"trace_method",
1321
]

swarms/telemetry/otel.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import functools
2+
import inspect
3+
import os
4+
from contextlib import contextmanager
5+
from typing import Any, Callable, Dict, Optional
6+
7+
8+
_TRACER = None
9+
_SETUP_ATTEMPTED = False
10+
11+
12+
def _otel_enabled() -> bool:
13+
value = os.getenv("SWARMS_OTEL_ENABLED", "").lower()
14+
return value in {"1", "true", "yes", "on"} or bool(
15+
os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
16+
)
17+
18+
19+
def _safe_value(value: Any) -> Any:
20+
if value is None or isinstance(value, (bool, int, float, str)):
21+
return value
22+
if isinstance(value, (list, tuple, set)):
23+
return len(value)
24+
if isinstance(value, dict):
25+
return len(value)
26+
return str(value)
27+
28+
29+
def _object_attributes(obj: Any) -> Dict[str, Any]:
30+
attrs: Dict[str, Any] = {
31+
"swarms.component": obj.__class__.__name__,
32+
}
33+
34+
for source_attr, otel_attr in (
35+
("id", "swarms.id"),
36+
("name", "swarms.name"),
37+
("agent_name", "swarms.agent_name"),
38+
("swarm_type", "swarms.swarm_type"),
39+
("output_type", "swarms.output_type"),
40+
):
41+
value = getattr(obj, source_attr, None)
42+
if value is not None:
43+
attrs[otel_attr] = _safe_value(value)
44+
45+
agents = getattr(obj, "agents", None)
46+
if agents is not None:
47+
try:
48+
attrs["swarms.agents.count"] = len(agents)
49+
except TypeError:
50+
pass
51+
52+
return attrs
53+
54+
55+
def setup_otel() -> Optional[Any]:
56+
global _TRACER, _SETUP_ATTEMPTED
57+
58+
if _TRACER is not None:
59+
return _TRACER
60+
if _SETUP_ATTEMPTED or not _otel_enabled():
61+
return None
62+
63+
_SETUP_ATTEMPTED = True
64+
65+
try:
66+
from opentelemetry import trace
67+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
68+
OTLPSpanExporter,
69+
)
70+
from opentelemetry.sdk.resources import Resource
71+
from opentelemetry.sdk.trace import TracerProvider
72+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
73+
except Exception:
74+
return None
75+
76+
resource = Resource.create(
77+
{
78+
"service.name": os.getenv(
79+
"SWARMS_OTEL_SERVICE_NAME", "swarms"
80+
)
81+
}
82+
)
83+
provider = TracerProvider(resource=resource)
84+
provider.add_span_processor(
85+
BatchSpanProcessor(
86+
OTLPSpanExporter(
87+
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
88+
)
89+
)
90+
)
91+
trace.set_tracer_provider(provider)
92+
_TRACER = trace.get_tracer("swarms")
93+
return _TRACER
94+
95+
96+
@contextmanager
97+
def otel_span(name: str, attributes: Optional[Dict[str, Any]] = None):
98+
tracer = setup_otel()
99+
if tracer is None:
100+
yield None
101+
return
102+
103+
try:
104+
from opentelemetry.trace import Status, StatusCode
105+
except Exception:
106+
yield None
107+
return
108+
109+
safe_attrs = {
110+
key: _safe_value(value)
111+
for key, value in (attributes or {}).items()
112+
if value is not None
113+
}
114+
115+
with tracer.start_as_current_span(name) as span:
116+
for key, value in safe_attrs.items():
117+
span.set_attribute(key, value)
118+
try:
119+
yield span
120+
except Exception as exc:
121+
span.record_exception(exc)
122+
span.set_status(Status(StatusCode.ERROR, str(exc)))
123+
raise
124+
125+
126+
def trace_method(
127+
span_name: str,
128+
attributes_factory: Optional[
129+
Callable[[Any, tuple, dict], Dict[str, Any]]
130+
] = None,
131+
):
132+
def decorator(func):
133+
if inspect.iscoroutinefunction(func):
134+
135+
@functools.wraps(func)
136+
async def async_wrapper(self, *args, **kwargs):
137+
attributes = _object_attributes(self)
138+
if attributes_factory is not None:
139+
attributes.update(
140+
attributes_factory(self, args, kwargs)
141+
)
142+
with otel_span(span_name, attributes):
143+
return await func(self, *args, **kwargs)
144+
145+
return async_wrapper
146+
147+
@functools.wraps(func)
148+
def wrapper(self, *args, **kwargs):
149+
attributes = _object_attributes(self)
150+
if attributes_factory is not None:
151+
attributes.update(attributes_factory(self, args, kwargs))
152+
with otel_span(span_name, attributes):
153+
return func(self, *args, **kwargs)
154+
155+
return wrapper
156+
157+
return decorator

tests/telemetry/test_otel.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import importlib.util
2+
from pathlib import Path
3+
4+
5+
def load_otel_module():
6+
path = (
7+
Path(__file__).parents[2]
8+
/ "swarms"
9+
/ "telemetry"
10+
/ "otel.py"
11+
)
12+
spec = importlib.util.spec_from_file_location(
13+
"test_swarms_otel", path
14+
)
15+
module = importlib.util.module_from_spec(spec)
16+
spec.loader.exec_module(module)
17+
return module
18+
19+
20+
def test_trace_method_is_noop_when_disabled(monkeypatch):
21+
otel = load_otel_module()
22+
monkeypatch.delenv("SWARMS_OTEL_ENABLED", raising=False)
23+
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
24+
25+
class Dummy:
26+
name = "example"
27+
28+
@otel.trace_method("dummy.run")
29+
def run(self, value):
30+
return value + 1
31+
32+
assert Dummy().run(1) == 2
33+
34+
35+
def test_object_attributes_exclude_task_content():
36+
otel = load_otel_module()
37+
38+
class Dummy:
39+
id = "abc"
40+
name = "workflow"
41+
agent_name = "agent"
42+
output_type = "dict"
43+
agents = [object(), object()]
44+
45+
attrs = otel._object_attributes(Dummy())
46+
47+
assert attrs["swarms.component"] == "Dummy"
48+
assert attrs["swarms.id"] == "abc"
49+
assert attrs["swarms.name"] == "workflow"
50+
assert attrs["swarms.agent_name"] == "agent"
51+
assert attrs["swarms.output_type"] == "dict"
52+
assert attrs["swarms.agents.count"] == 2
53+
assert "task" not in attrs

0 commit comments

Comments
 (0)