Skip to content
Merged
Show file tree
Hide file tree
Changes from 84 commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
7c8dc91
feat(datagen): recording toolkit and hand-recorded OpenInference corpora
anticorrelator Aug 20, 2026
034e7df
feat(datagen): add OTLP corpus replayer and phoenix datagen CLI
anticorrelator Aug 20, 2026
4be0535
fix(datagen): group corpus spans across requests
anticorrelator Aug 20, 2026
524ac4b
fix(datagen): preserve replay fidelity and package corpora
anticorrelator Aug 20, 2026
9522215
fix(datagen): address acceptance findings
anticorrelator Aug 21, 2026
b12b763
feat: add optional datagen deployment recipes
anticorrelator Aug 21, 2026
c5328f5
refactor(datagen): rename corpora to datagen assets and scenarios
anticorrelator Aug 21, 2026
dd3a20a
feat(datagen): fragment banks, generation lanes, session composer, di…
anticorrelator Aug 21, 2026
2476532
fix(datagen): make generation-tooling tests importable without PYTHON…
anticorrelator Aug 21, 2026
b15145f
fix(datagen): resolve recorder environments and verify offline recording
anticorrelator Aug 21, 2026
088e35a
chore(datagen): re-record starter assets under current instrumenter pins
anticorrelator Aug 21, 2026
cff4301
feat(datagen): move assets to GCS
anticorrelator Aug 21, 2026
6b9111c
Merge branch 'dustin/datagen-assets-on-gcs' into dustin/data-generati…
anticorrelator Aug 21, 2026
ddf6259
feat(datagen): application profiles, profile-scoped matrix, structure…
anticorrelator Aug 21, 2026
6fa6dfe
feat(datagen): add customer support profiles
anticorrelator Aug 21, 2026
a83bca3
feat(datagen): add coding agent application profiles
anticorrelator Aug 21, 2026
ca9ce63
feat(datagen): add data analyst application profiles
anticorrelator Aug 21, 2026
90c25f3
feat(datagen): add deep research profiles
anticorrelator Aug 21, 2026
80e82a5
feat(datagen): deterministic seed mechanics and materialized environm…
anticorrelator Aug 22, 2026
40d7538
test(datagen): add seed mechanics to generation fixture
anticorrelator Aug 22, 2026
64f316b
feat(datagen): add deep research seed mechanics
anticorrelator Aug 22, 2026
4058bfb
feat(datagen): add data analyst seed mechanics
anticorrelator Aug 22, 2026
c1ed58f
feat(datagen): add coding agent seed mechanics
anticorrelator Aug 22, 2026
d81c30f
feat(datagen): add customer support seed mechanics
anticorrelator Aug 22, 2026
6380261
feat(datagen): judged outcomes with engagement-based routing
anticorrelator Aug 22, 2026
58cc5d6
fix(datagen): align profile and composition boundaries
anticorrelator Aug 22, 2026
470e6cd
fix(datagen): make asset publication owner-run
anticorrelator Aug 22, 2026
519a2c0
fix(datagen): reject invalid conversation structure
anticorrelator Aug 22, 2026
e1d464d
feat(datagen): replay rate schedule, backfill, and error injection
anticorrelator Aug 22, 2026
bf33c5d
feat(datagen): supplemental fault runs and bank merge
anticorrelator Aug 22, 2026
c745ce2
refactor(datagen): trim runtime verification to its floor
anticorrelator Aug 25, 2026
282fa85
refactor(datagen): remove the cost plane and the batch lane
anticorrelator Aug 25, 2026
f5791e2
refactor(datagen): one shared serialization module for the sidecar sc…
anticorrelator Aug 25, 2026
8e2daed
refactor(datagen): share the transcript hygiene names across the guards
anticorrelator Aug 25, 2026
a8dbe0c
refactor(datagen): rename bank to scenario and enforce judged outcome…
anticorrelator Aug 25, 2026
c6a9db3
refactor(datagen): scenario vocabulary and one owner per publish check
anticorrelator Aug 25, 2026
13502a8
style(datagen): format test_codex_exec.py
anticorrelator Aug 25, 2026
ff9f5af
refactor(datagen): default the destination project to phoenix-datagen
anticorrelator Aug 25, 2026
a147b1c
refactor(datagen): zero-config replay with bundled or sole published …
anticorrelator Aug 25, 2026
cacf220
refactor(datagen): drop the seven session-shape tuning flags
anticorrelator Aug 25, 2026
076c845
refactor(datagen): remove backfill, rate schedules, and the anomaly m…
anticorrelator Aug 25, 2026
2f9cec6
Relax datagen replay validation and cache checks
anticorrelator Aug 25, 2026
2b80ccc
Trim datagen generation checks and tests
anticorrelator Aug 25, 2026
211aa17
Flatten datagen's published banks into a single corpus
anticorrelator Aug 25, 2026
8b0880b
Trim the datagen replayer to its live paths
anticorrelator Aug 26, 2026
a8f3656
feat(datagen): simplify corpus archive pipeline
anticorrelator Aug 26, 2026
1a2e58b
refactor(datagen): simplify trace replay
anticorrelator Aug 26, 2026
585b13a
feat(datagen): replace generation runs with recorder fixtures
anticorrelator Aug 26, 2026
e8df019
refactor(datagen): record archetypes from fixed fixtures
anticorrelator Aug 26, 2026
b539c4f
refactor(datagen): align deployment with corpus replay
anticorrelator Aug 26, 2026
77d583c
fix(datagen): satisfy repository type checks
anticorrelator Aug 26, 2026
374c4d7
feat(datagen): add recorder condition materialization
anticorrelator Aug 26, 2026
7af90dc
feat(datagen): add conditioned live recording lane
anticorrelator Aug 27, 2026
6e6f676
fix(datagen): skip llama-index recorder test when instrumenter is absent
anticorrelator Aug 27, 2026
b9021f4
fix(datagen): skip guardrail recorder test when framework is absent
anticorrelator Aug 27, 2026
e548e13
test(datagen): trim suite to one happy path per surface
anticorrelator Aug 27, 2026
bed50ae
feat(datagen): add iterative coding tool traces
anticorrelator Aug 27, 2026
4453d11
feat(datagen): enrich authored corpus inputs
anticorrelator Aug 27, 2026
58fdefb
feat(datagen): report corpus depth statistics
anticorrelator Aug 27, 2026
023cb18
feat(datagen): simulate live chat follow-up users
anticorrelator Aug 27, 2026
095c6c3
fix(datagen): suppress simulated user spans
anticorrelator Aug 27, 2026
90bee3c
fix(datagen): resolve luna recorder model
anticorrelator Aug 27, 2026
ce47250
fix(datagen): configure luna tool calls
anticorrelator Aug 27, 2026
0dc7ac4
feat(datagen): vary simulated user dispositions
anticorrelator Aug 27, 2026
53d8791
feat(datagen): add manual agent phase spans
anticorrelator Aug 27, 2026
4982309
Weight replay session sampling by fragment count
anticorrelator Aug 27, 2026
7c35481
Add a fat-tail slow-span outlier to replay jitter
anticorrelator Aug 27, 2026
3566548
docs: replace internal vocabulary with plain terms
anticorrelator Aug 27, 2026
caaa985
Fix CI: formatting, redundant cast, and datagen script type checking
anticorrelator Aug 27, 2026
27b1348
Fix datagen container start commands for the distroless image
anticorrelator Aug 27, 2026
f3b7e26
Prefix replayed session ids with their domain
anticorrelator Aug 27, 2026
7f53499
Give each archetype its own session-length profile
anticorrelator Aug 27, 2026
bdec7ab
Steer conversation length organically and diversify coding seeds
anticorrelator Aug 27, 2026
5d519fe
Let chat sessions chain a few whole conversations
anticorrelator Aug 27, 2026
738000a
Apply ruff formatting to datagen recorder and test
anticorrelator Aug 27, 2026
1cad4d0
Vary chat conversation openings per live run
anticorrelator Aug 27, 2026
17e561d
Merge remote-tracking branch 'origin/main' into dustin/data-generatio…
anticorrelator Aug 27, 2026
5e2e4d1
Restore scripts/ in the unit-test checkout and pin the chat test opening
anticorrelator Aug 27, 2026
cc7225d
Scope the scripted coding-agent test to fixtures with scripted episodes
anticorrelator Aug 27, 2026
f224791
Format the coding-agent test selection
anticorrelator Aug 27, 2026
57dfd31
Move datagen tooling tests out of the unit suite
anticorrelator Aug 27, 2026
c1d3edf
Narrow this PR to the datagen replayer runtime
anticorrelator Aug 28, 2026
6bba085
fix(ci): cap pydantic-ai-slim below 2.34 in unit test requirements
anticorrelator Aug 28, 2026
1ea2018
Keep jittered token totals consistent when one component is missing
anticorrelator Aug 28, 2026
4055fd6
Merge remote-tracking branch 'origin/main' into dustin/data-generatio…
anticorrelator Aug 28, 2026
e35a25d
Drop the pydantic-ai-slim unit-test cap after the vendored re-sync
anticorrelator Aug 28, 2026
d5a070b
Inline PHOENIX_CLIENT_HEADERS parsing in the datagen command
anticorrelator Aug 28, 2026
e751dd3
Mark datagen as internal tooling and move it under experimental
anticorrelator Aug 28, 2026
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
4 changes: 4 additions & 0 deletions requirements/unit-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ grpc-interceptor[testing]
httpx
litellm>=1.83.14; python_version < '3.14'
nest-asyncio # for executor testing
# The vendored data-stream protocol types in src/phoenix/db/types are at
# pydantic-ai 2.33 parity; test_data_stream_protocol_compatibility fails on
# newer releases. Re-sync the vendored types before lifting this cap.
pydantic-ai-slim<2.34
numpy
pandas-stubs==2.0.3.230814
pandas>=1.0
Expand Down
27 changes: 27 additions & 0 deletions src/phoenix/datagen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Replay recorded OpenInference traces into a Phoenix collector."""
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
Comment thread
anticorrelator marked this conversation as resolved.
Outdated

from phoenix.datagen.exporter import OTLPHTTPExporter
from phoenix.datagen.fetcher import CorpusFetchError, fetch_corpus, load_corpus_pointer
from phoenix.datagen.loader import Corpus, CorpusError, load_corpus
from phoenix.datagen.replayer import Replayer
from phoenix.datagen.schema import (
ARCHETYPES,
Archetype,
Fragment,
SchemaValidationError,
)

__all__ = [
"ARCHETYPES",
"Archetype",
"Corpus",
"CorpusError",
"CorpusFetchError",
"Fragment",
"OTLPHTTPExporter",
"Replayer",
"SchemaValidationError",
"fetch_corpus",
"load_corpus",
"load_corpus_pointer",
]
188 changes: 188 additions & 0 deletions src/phoenix/datagen/composer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Compose recorded fragments into virtual replay sessions."""

from __future__ import annotations

from dataclasses import dataclass
from math import log
from typing import Mapping, NamedTuple, Sequence

import numpy as np
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
)

from phoenix.datagen.loader import Corpus
from phoenix.datagen.schema import Archetype, Fragment


class _SessionLengthProfile(NamedTuple):
"""Lognormal draw parameters for fragments per composed session."""

median: float
sigma: float
maximum: int


# Session lengths differ by application shape. Episodic applications (agent
# work sessions, batch extraction) naturally string together several
# independent fragments; conversational applications record whole
# conversations as single fragments, so their sessions compose few of them.
# Medians are fragments per session; each fragment carries its recorded
# traces.
_SESSION_LENGTH_PROFILES: Mapping[Archetype, _SessionLengthProfile] = {
"tool_agent": _SessionLengthProfile(median=6.0, sigma=0.8, maximum=30),
"plain_chat": _SessionLengthProfile(median=2.0, sigma=0.7, maximum=4),
"rag": _SessionLengthProfile(median=2.0, sigma=0.8, maximum=6),
"structured_extraction": _SessionLengthProfile(median=4.0, sigma=1.0, maximum=16),
"graph_multi_agent": _SessionLengthProfile(median=2.0, sigma=0.8, maximum=6),
"guardrailed": _SessionLengthProfile(median=3.0, sigma=0.8, maximum=8),
}
_DEFAULT_SESSION_LENGTH = _SessionLengthProfile(median=2.0, sigma=1.0, maximum=24)
_FRAGMENT_GAP_MEDIAN_SECONDS = 180.0
_FRAGMENT_GAP_SIGMA = 0.9
_FRAGMENT_GAP_MAX_SECONDS = 3600.0


@dataclass(frozen=True)
class ComposedTrace:
"""One whole recorded trace placed on a virtual timeline."""

request: ExportTraceServiceRequest
fragment_id: str
virtual_start_ns: int


@dataclass(frozen=True)
class ComposedSession:
"""A same-domain, same-archetype sequence of whole recorded fragments."""

archetype: Archetype
fragments: Sequence[Fragment]
traces: Sequence[ComposedTrace]
start_time_ns: int
end_time_ns: int


class SessionComposer:
"""Sample fragments and place their recorded traces on a virtual timeline."""

def __init__(
self,
corpus: Corpus,
*,
random: np.random.Generator,
) -> None:
if not corpus.fragments:
raise ValueError("corpus contains no fragments")
self._random = random
self._requests_by_trace_id = corpus.requests_by_trace_id
fragments_by_application: dict[Archetype, dict[str, list[Fragment]]] = {}
for fragment in corpus.fragments:
fragments_by_application.setdefault(fragment.archetype, {}).setdefault(
fragment.domain, []
).append(fragment)
self._fragments_by_application: dict[Archetype, dict[str, tuple[Fragment, ...]]] = {
archetype: {
domain: tuple(fragments) for domain, fragments in sorted(applications.items())
}
for archetype, applications in fragments_by_application.items()
}
cells: list[tuple[Archetype, str]] = [
(archetype, domain)
for archetype, domains in self._fragments_by_application.items()
for domain in domains
]
cells.sort()
counts = np.array(
[len(self._fragments_by_application[archetype][domain]) for archetype, domain in cells],
dtype=np.float64,
)
self._cells = tuple(cells)
self._cell_probabilities = counts / counts.sum()

def compose(self, *, now_ns: int) -> ComposedSession:
"""Materialize one backdated session ending at ``now_ns``."""
cell_index = int(self._random.choice(len(self._cells), p=self._cell_probabilities))
archetype, domain = self._cells[cell_index]
fragments = self._sample_fragments(archetype, domain, self._draw_fragment_count(archetype))
traces: list[ComposedTrace] = []
cursor_ns = 0
for fragment_index, fragment in enumerate(fragments):
requests = tuple(
self._requests_by_trace_id[trace_id] for trace_id in fragment.trace_ids
)
starts_and_ends = tuple(_request_bounds(request) for request in requests)
fragment_start_ns = min(start for start, _ in starts_and_ends)
fragment_end_ns = max(end for _, end in starts_and_ends)
for request, (trace_start_ns, _) in zip(requests, starts_and_ends):
traces.append(
ComposedTrace(
request=request,
fragment_id=fragment.fragment_id,
virtual_start_ns=cursor_ns + trace_start_ns - fragment_start_ns,
)
)
cursor_ns += fragment_end_ns - fragment_start_ns
if fragment_index < len(fragments) - 1:
cursor_ns += self._draw_fragment_gap_ns()

session_start_ns = now_ns - cursor_ns
shifted_traces = tuple(
ComposedTrace(
request=trace.request,
fragment_id=trace.fragment_id,
virtual_start_ns=session_start_ns + trace.virtual_start_ns,
)
for trace in traces
)
return ComposedSession(
archetype=archetype,
fragments=fragments,
traces=shifted_traces,
start_time_ns=session_start_ns,
end_time_ns=now_ns,
)

def _draw_fragment_count(self, archetype: Archetype) -> int:
profile = _SESSION_LENGTH_PROFILES.get(archetype, _DEFAULT_SESSION_LENGTH)
count = int(
round(
self._random.lognormal(
mean=log(profile.median),
sigma=profile.sigma,
)
)
)
return min(profile.maximum, max(1, count))

def _draw_fragment_gap_ns(self) -> int:
seconds = self._random.lognormal(
mean=log(_FRAGMENT_GAP_MEDIAN_SECONDS),
sigma=_FRAGMENT_GAP_SIGMA,
)
seconds = min(_FRAGMENT_GAP_MAX_SECONDS, max(0.0, float(seconds)))
return round(seconds * 1_000_000_000)

def _sample_fragments(
self, archetype: Archetype, domain: str, count: int
) -> tuple[Fragment, ...]:
available = self._fragments_by_application[archetype][domain]
selected: list[Fragment] = []
while len(selected) < count:
batch_size = min(len(available), count - len(selected))
indices = self._random.choice(len(available), size=batch_size, replace=False)
selected.extend(available[int(index)] for index in np.atleast_1d(indices))
return tuple(selected)


def _request_bounds(request: ExportTraceServiceRequest) -> tuple[int, int]:
spans = tuple(
span
for resource_spans in request.resource_spans
for scope_spans in resource_spans.scope_spans
for span in scope_spans.spans
)
return (
min(span.start_time_unix_nano for span in spans),
max(span.end_time_unix_nano for span in spans),
)
70 changes: 70 additions & 0 deletions src/phoenix/datagen/exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Export replayed trace requests over OTLP/HTTP protobuf."""

from __future__ import annotations

import logging
from types import TracebackType
from typing import Mapping
from urllib.parse import urlsplit, urlunsplit

import httpx
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
)

logger = logging.getLogger(__name__)


class OTLPHTTPExporter:
"""Send encoded trace requests to an OTLP/HTTP collector."""

def __init__(
self,
endpoint: str,
*,
api_key: str | None = None,
headers: Mapping[str, str] | None = None,
timeout: float = 30.0,
) -> None:
request_headers = dict(headers or {})
request_headers["Content-Type"] = "application/x-protobuf"
if api_key and not any(key.lower() == "authorization" for key in request_headers):
request_headers["Authorization"] = f"Bearer {api_key}"
self._endpoint = _trace_endpoint(endpoint)
self._client = httpx.Client(headers=request_headers, timeout=timeout)

def export(self, request: ExportTraceServiceRequest) -> bool:
"""Export one protobuf trace request, returning whether it was delivered."""
content = request.SerializeToString()
try:
response = self._client.post(self._endpoint, content=content)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning("OTLP export failed: %s", str(error).replace("\n", " "))
return False
return True

def close(self) -> None:
"""Close the persistent HTTP connection pool."""
self._client.close()

def __enter__(self) -> OTLPHTTPExporter:
return self

def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.close()


def _trace_endpoint(endpoint: str) -> str:
if "://" not in endpoint:
endpoint = f"http://{endpoint}"
split = urlsplit(endpoint)
path = split.path.rstrip("/")
if not path.endswith("/v1/traces"):
path = f"{path}/v1/traces"
return urlunsplit((split.scheme, split.netloc, path, split.query, split.fragment))
Loading
Loading