Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CONFIG_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions p2m/core/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'. "
Expand Down
20 changes: 20 additions & 0 deletions p2m/core/otel_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions p2m/stages/rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
63 changes: 63 additions & 0 deletions tests/test_framework_agnostic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading