mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-21 10:26:25 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
* feat(tracing): port enterprise event sessions to OSS * fix(tracing): address review findings and verify concurrent exports * fix(tracing): keep redactor ownership in enterprise integrations * test(tracing): isolate intentional failures from cleanup assertions
333 lines
12 KiB
Python
333 lines
12 KiB
Python
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import Mock, patch
|
|
from uuid import uuid4
|
|
|
|
from crewai import Agent, Crew, Task
|
|
from crewai.events.event_bus import crewai_event_bus
|
|
from crewai.events.types.llm_events import (
|
|
LLMCallCompletedEvent,
|
|
LLMCallStartedEvent,
|
|
LLMCallType,
|
|
)
|
|
from crewai.execution import get_execution_uuid
|
|
from crewai.flow.flow import Flow, listen, start
|
|
from crewai.llms.base_llm import BaseLLM
|
|
from crewai.telemetry.tracing.context import get_trace_session
|
|
from crewai.telemetry.tracing.ephemeral import trace_consent
|
|
from crewai.telemetry.tracing.grants import (
|
|
GrantSpanExporter,
|
|
TraceGrantClient,
|
|
TraceGrantError,
|
|
)
|
|
from opentelemetry import trace
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def tracing_environment(monkeypatch):
|
|
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
|
monkeypatch.delenv("CREWAI_USER_PAT", raising=False)
|
|
monkeypatch.delenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", raising=False)
|
|
monkeypatch.delenv("CREWAI_EPHEMERAL_TRACE_MAX_SPANS", raising=False)
|
|
monkeypatch.delenv("CREWAI_EPHEMERAL_TRACE_MAX_BYTES", raising=False)
|
|
monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true")
|
|
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", "true")
|
|
monkeypatch.setattr("crewai.telemetry.tracing.grants.get_auth_token", lambda: None)
|
|
|
|
|
|
class ExampleFlow(Flow):
|
|
@start()
|
|
def first(self):
|
|
return "hello"
|
|
|
|
@listen(first)
|
|
def second(self, value):
|
|
return value + " world"
|
|
|
|
|
|
@pytest.fixture
|
|
def in_memory_grant_collectors(monkeypatch):
|
|
"""Exercise grant lifetimes and consent without opening any sockets."""
|
|
from crewai.telemetry.tracing.grants import TraceGrant
|
|
|
|
issued, recorders = [], {}
|
|
|
|
def create(client, execution_uuid):
|
|
grant = TraceGrant(
|
|
token=f"synthetic-grant-{len(issued)}",
|
|
collector_url="https://collector.invalid/v1/traces",
|
|
execution_uuid=execution_uuid,
|
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
)
|
|
issued.append(grant)
|
|
return grant
|
|
|
|
def exporter(grant):
|
|
recorder = InMemorySpanExporter()
|
|
recorders[grant.execution_uuid] = recorder
|
|
return recorder
|
|
|
|
monkeypatch.setattr(TraceGrantClient, "create", create)
|
|
monkeypatch.setattr(GrantSpanExporter, "_exporter", staticmethod(exporter))
|
|
return issued, recorders
|
|
|
|
|
|
@pytest.mark.parametrize("authenticated", [False, True])
|
|
@pytest.mark.parametrize("deferred", [False, True])
|
|
@pytest.mark.parametrize("async_run", [False, True])
|
|
def test_pause_resume_exports_traces_and_preserves_deferred_root(
|
|
in_memory_grant_collectors, monkeypatch, authenticated, deferred, async_run
|
|
):
|
|
from crewai.flow.async_feedback.types import (
|
|
HumanFeedbackPending,
|
|
PendingFeedbackContext,
|
|
)
|
|
from crewai.flow.persistence.base import FlowPersistence
|
|
|
|
issued, recorders = in_memory_grant_collectors
|
|
if authenticated:
|
|
monkeypatch.setenv("CREWAI_USER_PAT", "synthetic-pat")
|
|
|
|
class MemoryPersistence(FlowPersistence):
|
|
def init_db(self):
|
|
pass
|
|
|
|
def save_state(self, flow_uuid, method_name, state_data):
|
|
pass
|
|
|
|
def load_state(self, flow_uuid):
|
|
return None
|
|
|
|
class PausingFlow(Flow):
|
|
@start()
|
|
def review(self):
|
|
context = PendingFeedbackContext(
|
|
flow_id=self.flow_id,
|
|
flow_class="PausingFlow",
|
|
method_name="review",
|
|
method_output="draft",
|
|
message="Review this draft",
|
|
execution_uuid=get_execution_uuid(),
|
|
)
|
|
self._pending_feedback_context = context
|
|
raise HumanFeedbackPending(context)
|
|
|
|
@listen(review)
|
|
def finish(self, feedback):
|
|
return feedback.feedback
|
|
|
|
with patch(
|
|
"crewai.telemetry.tracing.ephemeral.prompt_user_for_trace_viewing",
|
|
return_value=True,
|
|
) as prompt:
|
|
flow = PausingFlow(
|
|
persistence=MemoryPersistence(),
|
|
tracing=True,
|
|
defer_trace_finalization=deferred,
|
|
)
|
|
result = asyncio.run(flow.kickoff_async()) if async_run else flow.kickoff()
|
|
assert isinstance(result, HumanFeedbackPending)
|
|
execution_uuid = result.context.execution_uuid
|
|
lifetime = flow._deferred_execution_trace
|
|
opener = flow._deferred_flow_started_event_id
|
|
if deferred:
|
|
assert lifetime is not None and not lifetime.closed
|
|
else:
|
|
assert lifetime is None
|
|
# A successful pause must export normally, including anonymous consent.
|
|
paused = recorders[execution_uuid].get_finished_spans()
|
|
assert paused
|
|
assert all(
|
|
span.status.status_code != trace.StatusCode.ERROR for span in paused
|
|
)
|
|
assert prompt.call_count == (0 if authenticated or deferred else 1)
|
|
assert get_trace_session() is None and get_execution_uuid() is None
|
|
|
|
resumed = (
|
|
asyncio.run(flow.resume_async("approved"))
|
|
if async_run
|
|
else flow.resume("approved")
|
|
)
|
|
assert resumed == "approved"
|
|
assert flow._deferred_flow_started_event_id == opener
|
|
assert flow._deferred_execution_trace is lifetime
|
|
if deferred:
|
|
assert not lifetime.closed
|
|
assert get_trace_session() is None and get_execution_uuid() is None
|
|
|
|
flow.finalize_session_traces()
|
|
flow.finalize_session_traces()
|
|
assert prompt.call_count == (0 if authenticated else (1 if deferred else 2))
|
|
|
|
assert flow._deferred_execution_trace is None
|
|
assert len(issued) == (1 if deferred else 2)
|
|
if deferred:
|
|
assert lifetime.closed
|
|
exported = recorders[execution_uuid].get_finished_spans()
|
|
roots = [span for span in exported if span.name == "execute flow"]
|
|
assert len(roots) == 1
|
|
if deferred:
|
|
assert roots[0].attributes["event_id"] == opener
|
|
assert len({span.context.trace_id for span in exported}) == 1
|
|
assert all(
|
|
span.parent == roots[0].context
|
|
for span in exported
|
|
if span.name == "call method"
|
|
)
|
|
assert all(span.status.status_code == trace.StatusCode.OK for span in roots)
|
|
assert all(span.end_time is not None for span in roots)
|
|
assert [span.name for span in exported].count("call method") == (
|
|
3 if deferred else 2
|
|
)
|
|
assert {span.attributes["crewai.execution_uuid"] for span in exported} == {
|
|
execution_uuid
|
|
}
|
|
assert get_trace_session() is None and get_execution_uuid() is None
|
|
|
|
|
|
class LocalLLM(BaseLLM):
|
|
def __init__(self):
|
|
super().__init__(model="local-test")
|
|
|
|
def call(self, messages, **kwargs):
|
|
call_id = str(uuid4())
|
|
crewai_event_bus.emit(
|
|
self, LLMCallStartedEvent(call_id=call_id, messages=messages)
|
|
)
|
|
crewai_event_bus.emit(
|
|
self,
|
|
LLMCallCompletedEvent(
|
|
call_id=call_id,
|
|
response="Final Answer: hello",
|
|
call_type=LLMCallType.LLM_CALL,
|
|
),
|
|
)
|
|
return "Final Answer: hello"
|
|
|
|
async def acall(self, messages, **kwargs):
|
|
return self.call(messages, **kwargs)
|
|
|
|
def supports_function_calling(self):
|
|
return False
|
|
|
|
def supports_stop_words(self):
|
|
return False
|
|
|
|
|
|
@pytest.mark.parametrize("async_run", [False, True])
|
|
@pytest.mark.parametrize("nested", [False, True])
|
|
def test_agent_and_nested_crew_use_one_event_session(
|
|
in_memory_grant_collectors, monkeypatch, async_run, nested
|
|
):
|
|
issued, recorders = in_memory_grant_collectors
|
|
monkeypatch.setenv("CREWAI_USER_PAT", "synthetic-pat")
|
|
agent = Agent(role="tester", goal="greet", backstory="tester", llm=LocalLLM())
|
|
crew = Crew(
|
|
agents=[agent],
|
|
tasks=[Task(description="say hello", expected_output="hello", agent=agent)],
|
|
tracing=True,
|
|
)
|
|
|
|
class CrewFlow(Flow):
|
|
@start()
|
|
async def run(self):
|
|
return await crew.akickoff()
|
|
|
|
if nested:
|
|
flow = CrewFlow(tracing=True)
|
|
result = asyncio.run(flow.kickoff_async()) if async_run else flow.kickoff()
|
|
else:
|
|
result = (
|
|
asyncio.run(agent.kickoff_async("hello"))
|
|
if async_run
|
|
else agent.kickoff("hello")
|
|
)
|
|
assert "hello" in result.raw
|
|
assert len(issued) == 1
|
|
exported = recorders[issued[0].execution_uuid].get_finished_spans()
|
|
assert exported
|
|
assert len({s.context.trace_id for s in exported}) == 1
|
|
assert {s.attributes["crewai.execution_uuid"] for s in exported} == {
|
|
issued[0].execution_uuid
|
|
}
|
|
roots = [s for s in exported if s.parent is None]
|
|
assert [s.name for s in roots] == [
|
|
"execute flow" if nested else "execute lite agent"
|
|
]
|
|
assert get_execution_uuid() is None and get_trace_session() is None
|
|
|
|
|
|
@pytest.mark.parametrize("approved", [False, True])
|
|
def test_first_time_uses_local_session_even_with_saved_login(
|
|
in_memory_grant_collectors, monkeypatch, approved
|
|
):
|
|
issued, _ = in_memory_grant_collectors
|
|
monkeypatch.delenv("CREWAI_TRACING_ENABLED")
|
|
monkeypatch.setattr(
|
|
"crewai.events.listeners.tracing.utils.should_enable_tracing", lambda **_: False
|
|
)
|
|
monkeypatch.setattr(
|
|
"crewai.events.listeners.tracing.utils.should_auto_collect_first_time_traces",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"crewai.telemetry.tracing.grants.get_auth_token", lambda: "saved-login"
|
|
)
|
|
persist = Mock()
|
|
monkeypatch.setattr("crewai.telemetry.tracing.ephemeral.update_user_data", persist)
|
|
legacy = Mock(side_effect=AssertionError("legacy trace transport used"))
|
|
monkeypatch.setattr(
|
|
"crewai.events.listeners.tracing.trace_batch_manager.TraceBatchManager.initialize_batch",
|
|
legacy,
|
|
)
|
|
|
|
def consent():
|
|
assert not issued
|
|
assert get_trace_session().context.active_spans == {}
|
|
return approved
|
|
|
|
with trace_consent(consent):
|
|
assert ExampleFlow().kickoff() == "hello world"
|
|
assert len(issued) == int(approved)
|
|
persist.assert_called_once_with(
|
|
{"first_execution_done": True, "trace_consent": approved}
|
|
)
|
|
legacy.assert_not_called()
|
|
assert get_trace_session() is None and get_execution_uuid() is None
|
|
|
|
|
|
@pytest.mark.parametrize("disabled", ["flag", "env", "sdk"])
|
|
def test_explicit_disable_prevents_first_time_collection(monkeypatch, disabled):
|
|
monkeypatch.setattr(
|
|
"crewai.events.listeners.tracing.utils.should_auto_collect_first_time_traces",
|
|
lambda: True,
|
|
)
|
|
grant = Mock(side_effect=AssertionError("unexpected grant"))
|
|
monkeypatch.setattr(TraceGrantClient, "create", grant)
|
|
monkeypatch.setenv("CREWAI_USER_PAT", "synthetic-pat")
|
|
if disabled == "env":
|
|
monkeypatch.setenv("CREWAI_TRACING_ENABLED", "false")
|
|
if disabled == "sdk":
|
|
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
|
|
flow = ExampleFlow(tracing=False if disabled == "flag" else None)
|
|
assert flow.kickoff() == "hello world"
|
|
grant.assert_not_called()
|
|
assert get_trace_session() is None
|
|
|
|
|
|
@pytest.mark.parametrize("async_run", [False, True])
|
|
def test_grant_failure_restores_execution_context(monkeypatch, async_run):
|
|
monkeypatch.setenv("CREWAI_USER_PAT", "invalid")
|
|
monkeypatch.setattr(
|
|
TraceGrantClient,
|
|
"create",
|
|
Mock(side_effect=TraceGrantError("AMP rejected credential", 401)),
|
|
)
|
|
with pytest.raises(TraceGrantError) as error:
|
|
flow = ExampleFlow(tracing=True)
|
|
asyncio.run(flow.kickoff_async()) if async_run else flow.kickoff()
|
|
assert error.value.status_code == 401
|
|
assert get_trace_session() is None and get_execution_uuid() is None
|