diff --git a/CONFIG_REFERENCE.md b/CONFIG_REFERENCE.md index f05d64db..aa8703d7 100644 --- a/CONFIG_REFERENCE.md +++ b/CONFIG_REFERENCE.md @@ -184,6 +184,7 @@ Accepted keys: - `trace` — mapping. Optional. Use with callable targets that emit OpenTelemetry spans. - `backend` — string. Default: `phoenix`. - `group_by` — string. Customer preview supports `session.id`. + - `project_name` — string. Optional. Routes spans to a named Phoenix project instead of `"default"`. Sets `PHOENIX_PROJECT_NAME` before importing the target module. - `tools` — mapping. Optional. Allowed only when `target.model` is set. - `module` — string. Use a Python tool backend module. - `toolset` — string. Use a toolset file. @@ -206,6 +207,7 @@ pipeline: trace: backend: phoenix group_by: session.id + project_name: travel-planner-eval auditor: model: name: azure/gpt-5.4-mini diff --git a/p2m/core/config_model.py b/p2m/core/config_model.py index 921e03e9..9865511f 100644 --- a/p2m/core/config_model.py +++ b/p2m/core/config_model.py @@ -93,8 +93,10 @@ def __post_init__(self) -> None: class TraceConfig: backend: str = "phoenix" group_by: str = "session.id" + project_name: str | None = None def __post_init__(self) -> None: + self.project_name = _normalize_optional_string(self.project_name) if self.group_by not in VALID_TRACE_GROUP_BY: raise ValueError( f"trace.group_by must be 'session.id'. " diff --git a/p2m/core/otel_session.py b/p2m/core/otel_session.py index 178c6221..281db1a0 100644 --- a/p2m/core/otel_session.py +++ b/p2m/core/otel_session.py @@ -69,6 +69,7 @@ def __init__( exporter: TraceExporter | None = None, collector: SpanCollector | None = None, group_by: str = "session.id", + project_name: str | None = None, system_prompt: str | None = None, message_timeout_s: float | None = None, max_events_per_turn: int = 50, @@ -77,6 +78,7 @@ def __init__( self._callable_ref = callable_ref self._collector = collector self._group_by = group_by + self._project_name = project_name self._system_prompt = system_prompt self._message_timeout_s = message_timeout_s self._max_events_per_turn = max_events_per_turn @@ -108,9 +110,21 @@ def session_metadata(self) -> dict[str, Any] | None: async def open(self) -> None: import io + import os import sys module_path, func_name = self._callable_ref.rsplit(":", 1) + + # Set Phoenix project name before importing the target module. + # Phoenix's register() reads PHOENIX_PROJECT_NAME from the + # environment when no explicit project_name is passed. By setting + # it here we route spans to a named project without interfering + # with the target's register() call or the set-once TracerProvider + # guard. + _prev_project_name = os.environ.get("PHOENIX_PROJECT_NAME") + if self._project_name: + os.environ["PHOENIX_PROJECT_NAME"] = self._project_name + # Suppress Phoenix/OTel banner output during module import. # Phoenix's register(verbose=True) prints a multi-line banner to # stdout when the target module calls register() at import time. @@ -120,6 +134,12 @@ async def open(self) -> None: mod = importlib.import_module(module_path) finally: sys.stdout = _orig_stdout + # Restore original env var state to avoid side effects. + if self._project_name: + if _prev_project_name is None: + os.environ.pop("PHOENIX_PROJECT_NAME", None) + else: + os.environ["PHOENIX_PROJECT_NAME"] = _prev_project_name self._callable = getattr(mod, func_name) sig = inspect.signature(self._callable) self._supports_history = "history" in sig.parameters diff --git a/p2m/stages/rollout.py b/p2m/stages/rollout.py index fe1a5650..03023ebc 100644 --- a/p2m/stages/rollout.py +++ b/p2m/stages/rollout.py @@ -475,6 +475,7 @@ def _build_target_session( system_prompt=target.system_prompt, message_timeout_s=rollout.tool_timeout_s, group_by=target.trace.group_by, + project_name=target.trace.project_name, live_otel=True, ) return CallableSession( diff --git a/tests/test_framework_agnostic.py b/tests/test_framework_agnostic.py index 900fec4e..a4be4840 100644 --- a/tests/test_framework_agnostic.py +++ b/tests/test_framework_agnostic.py @@ -615,6 +615,69 @@ async def _run(): finally: del sys.modules["_test_otel_target"] + def test_project_name_sets_env_var(self): + """project_name should set PHOENIX_PROJECT_NAME before module import.""" + from p2m.core.otel_session import OTelTracedSession + + import os + import sys + import types + + captured_env = {} + mod = types.ModuleType("_test_otel_projname") + _original_init = mod.__dict__.get("__init__") + + # Record the env var value at module import time + captured_env["at_import"] = os.environ.get("PHOENIX_PROJECT_NAME") + mod.target = lambda msg: "ok" + + # We need the env capture to happen at import time. + # Create a fresh module that captures env on import. + code = ( + "import os\n" + "CAPTURED_PROJECT = os.environ.get('PHOENIX_PROJECT_NAME')\n" + "def target(msg): return 'ok'\n" + ) + mod = types.ModuleType("_test_otel_projname") + exec(code, mod.__dict__) + # Don't register yet — we'll let open() do the import + + # Remove from sys.modules so open() triggers a fresh import + sys.modules.pop("_test_otel_projname", None) + + # Write a temp module file so importlib can find it + import tempfile + tmpdir = tempfile.mkdtemp() + modpath = os.path.join(tmpdir, "_test_otel_projname.py") + with open(modpath, "w") as f: + f.write(code) + + sys.path.insert(0, tmpdir) + try: + original_val = os.environ.get("PHOENIX_PROJECT_NAME") + session = OTelTracedSession( + callable_ref="_test_otel_projname:target", + project_name="my-test-project", + ) + + async def _run(): + await session.open() + await session.close() + + asyncio.run(_run()) + + # Verify the module captured the env var at import time + imported = sys.modules["_test_otel_projname"] + self.assertEqual(imported.CAPTURED_PROJECT, "my-test-project") + + # Verify env var is restored after open() + self.assertEqual(os.environ.get("PHOENIX_PROJECT_NAME"), original_val) + finally: + sys.path.remove(tmpdir) + sys.modules.pop("_test_otel_projname", None) + import shutil + shutil.rmtree(tmpdir, ignore_errors=True) + def test_run_turn_with_history(self): """OTelTracedSession should detect and pass history parameter.""" from p2m.core.otel_session import OTelTracedSession