Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
from opentelemetry.semconv_ai import SpanAttributes
import json

# Crew fields that are safe to stringify. Anything else -- LLM clients, the
# embedder config, memory handles -- can carry credentials in its repr.
CREW_FIELDS = ("id", "name", "process", "verbose", "memory", "cache", "planning",
"max_rpm", "share_crew")


def set_span_attribute(span: Span, name, value):
if value is not None:
Expand Down Expand Up @@ -97,48 +102,51 @@ def _process_llm(self):
self._set_attribute(SpanAttributes.GEN_AI_REQUEST_MAX_COMPLETION_TOKENS, value)

def _populate_crew_attributes(self):
for key, value in self.instance.__dict__.items():
if value is None:
continue
if key == "tasks":
self._parse_tasks(value)
elif key == "agents":
self._parse_agents(value)
else:
"""Collect the allowlisted Crew fields, plus its tasks and agents."""
for key in CREW_FIELDS:
value = getattr(self.instance, key, None)
if value is not None:
self.crew[key] = str(value)
self._parse_tasks(self.instance.tasks or [])
self._parse_agents(self.instance.agents or [])

def _populate_agent_attributes(self):
return self._extract_attributes(self.instance)
"""Collect the allowlisted Agent fields for a standalone agent span."""
return self._stringify(self._extract_agent_data(self.instance))

def _populate_task_attributes(self):
task_data = self._extract_attributes(self.instance)
if "agent" in task_data:
task_data["agent"] = self.instance.agent.role if self.instance.agent else None
return task_data
"""Collect the allowlisted Task fields for a standalone task span."""
return self._stringify(self._extract_task_data(self.instance))

def _populate_llm_attributes(self):
return self._extract_attributes(self.instance)
@staticmethod
def _stringify(data):
"""Render a field dict as span-attribute values, dropping the unset ones."""
return {key: str(value) for key, value in data.items() if value is not None}

def _parse_agents(self, agents):
self.crew["agents"] = [
self._extract_agent_data(agent) for agent in agents if agent is not None
]

def _parse_tasks(self, tasks):
self.crew["tasks"] = [
{
"agent": task.agent.role if task.agent else None,
"description": task.description,
"async_execution": task.async_execution,
"expected_output": task.expected_output,
"human_input": task.human_input,
"tools": task.tools,
"output_file": task.output_file,
}
for task in tasks
]
"""Attach the crew's tasks, each reduced to its allowlisted fields."""
self.crew["tasks"] = [self._extract_task_data(task) for task in tasks if task is not None]

def _extract_task_data(self, task):
"""Return the allowlisted Task fields, with the agent named by its role."""
return {
"id": str(task.id),
"agent": task.agent.role if task.agent else None,
"description": task.description,
"async_execution": task.async_execution,
"expected_output": task.expected_output,
"human_input": task.human_input,
"tools": self._serialize_tools(task.tools or []),
"output_file": task.output_file,
}

def _extract_agent_data(self, agent):
"""Return the allowlisted Agent fields, with the LLM named by its model."""
model = (
getattr(agent.llm, "model", None)
or getattr(agent.llm, "model_name", None)
Expand All @@ -151,24 +159,12 @@ def _extract_agent_data(self, agent):
"goal": agent.goal,
"backstory": agent.backstory,
"cache": agent.cache,
"config": agent.config,
"verbose": agent.verbose,
"allow_delegation": agent.allow_delegation,
"tools": agent.tools,
"tools": self._serialize_tools(agent.tools or []),
"max_iter": agent.max_iter,
"llm": str(model), }

def _extract_attributes(self, obj):
attributes = {}
for key, value in obj.__dict__.items():
if value is None:
continue
if key == "tools":
attributes[key] = self._serialize_tools(value)
else:
attributes[key] = str(value)
return attributes

def _serialize_tools(self, tools):
return json.dumps(
[
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Span attributes come from a fixed set of fields, never a __dict__ walk, so an
object we don't control (an LLM client, an embedder config) can't leak its
credentials through its repr.

Objects are constructed only -- no kickoff, no network.
"""

import pytest
from crewai import LLM, Agent, Crew, Task
from crewai.tools import BaseTool
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from opentelemetry.instrumentation.crewai.crewai_span_attributes import CrewAISpanAttributes

# Not a credential: an opaque marker with no provider shape, used only to prove
# that whatever is configured on an LLM or embedder stays off the span.
SENTINEL = "SENTINEL-NOT-A-KEY-9f3a"


class EchoTool(BaseTool):
"""A tool with nothing secret on it, so any leak in the span is the LLM's."""

name: str = "echo"
description: str = "Echoes the input back."

def _run(self, text: str = "") -> str:
"""Echo the input back; never called, the tool is only ever serialized."""
return text


def build_agent():
"""An Agent holding a credential on both its LLM and its embedder config."""
return Agent(
role="researcher",
goal="find things",
backstory="a fixed backstory",
llm=LLM(model="test-model", api_key=SENTINEL),
embedder={"provider": "openai", "config": {"api_key": SENTINEL}},
tools=[EchoTool()],
)


def build_task():
"""A Task whose agent holds the credential."""
return Task(description="a fixed description", expected_output="a fixed output",
agent=build_agent(), tools=[EchoTool()])


def build_crew():
"""A Crew holding a credential on its manager LLM and its embedder config."""
agent = build_agent()
return Crew(
agents=[agent],
tasks=[Task(description="a fixed description", expected_output="a fixed output", agent=agent)],
name="fixed-crew",
manager_llm=LLM(model="test-model", api_key=SENTINEL),
embedder={"provider": "openai", "config": {"api_key": SENTINEL}},
)


BUILDERS = {"Agent": build_agent, "Task": build_task, "Crew": build_crew}


def span_attributes(instance):
"""Return the attributes the instrumentation lands on a span for `instance`."""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
with provider.get_tracer(__name__).start_as_current_span("test") as span:
CrewAISpanAttributes(span=span, instance=instance)
return dict(exporter.get_finished_spans()[0].attributes)


@pytest.mark.parametrize("kind", list(BUILDERS))
def test_configured_credentials_never_reach_the_span(kind):
"""Nothing configured on an LLM or embedder is stringified onto the span."""
attrs = span_attributes(BUILDERS[kind]())

# Substring check: nested agents, tasks and tools are JSON-dumped into a
# single value, so a key-wise check would miss anything hidden inside them.
for key, value in attrs.items():
assert SENTINEL not in str(value), f"{key} carries configured LLM state"

# ...while the allowlisted fields are still emitted.
assert attrs[f"crewai.{kind.lower()}.id"]