diff --git a/packages/opentelemetry-instrumentation-crewai/opentelemetry/instrumentation/crewai/crewai_span_attributes.py b/packages/opentelemetry-instrumentation-crewai/opentelemetry/instrumentation/crewai/crewai_span_attributes.py index ab9da6c202..0875a859c3 100644 --- a/packages/opentelemetry-instrumentation-crewai/opentelemetry/instrumentation/crewai/crewai_span_attributes.py +++ b/packages/opentelemetry-instrumentation-crewai/opentelemetry/instrumentation/crewai/crewai_span_attributes.py @@ -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: @@ -97,27 +102,26 @@ 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"] = [ @@ -125,20 +129,24 @@ def _parse_agents(self, agents): ] 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) @@ -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( [ diff --git a/packages/opentelemetry-instrumentation-crewai/tests/test_span_attribute_allowlist.py b/packages/opentelemetry-instrumentation-crewai/tests/test_span_attribute_allowlist.py new file mode 100644 index 0000000000..1cc1b27fe2 --- /dev/null +++ b/packages/opentelemetry-instrumentation-crewai/tests/test_span_attribute_allowlist.py @@ -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"]