-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(datagen): add a sidecar that replays a recorded trace corpus #15614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 034e7df
feat(datagen): add OTLP corpus replayer and phoenix datagen CLI
anticorrelator 4be0535
fix(datagen): group corpus spans across requests
anticorrelator 524ac4b
fix(datagen): preserve replay fidelity and package corpora
anticorrelator 9522215
fix(datagen): address acceptance findings
anticorrelator b12b763
feat: add optional datagen deployment recipes
anticorrelator c5328f5
refactor(datagen): rename corpora to datagen assets and scenarios
anticorrelator dd3a20a
feat(datagen): fragment banks, generation lanes, session composer, di…
anticorrelator 2476532
fix(datagen): make generation-tooling tests importable without PYTHON…
anticorrelator b15145f
fix(datagen): resolve recorder environments and verify offline recording
anticorrelator 088e35a
chore(datagen): re-record starter assets under current instrumenter pins
anticorrelator cff4301
feat(datagen): move assets to GCS
anticorrelator 6b9111c
Merge branch 'dustin/datagen-assets-on-gcs' into dustin/data-generati…
anticorrelator ddf6259
feat(datagen): application profiles, profile-scoped matrix, structure…
anticorrelator 6fa6dfe
feat(datagen): add customer support profiles
anticorrelator a83bca3
feat(datagen): add coding agent application profiles
anticorrelator ca9ce63
feat(datagen): add data analyst application profiles
anticorrelator 90c25f3
feat(datagen): add deep research profiles
anticorrelator 80e82a5
feat(datagen): deterministic seed mechanics and materialized environm…
anticorrelator 40d7538
test(datagen): add seed mechanics to generation fixture
anticorrelator 64f316b
feat(datagen): add deep research seed mechanics
anticorrelator 4058bfb
feat(datagen): add data analyst seed mechanics
anticorrelator c1ed58f
feat(datagen): add coding agent seed mechanics
anticorrelator d81c30f
feat(datagen): add customer support seed mechanics
anticorrelator 6380261
feat(datagen): judged outcomes with engagement-based routing
anticorrelator 58cc5d6
fix(datagen): align profile and composition boundaries
anticorrelator 470e6cd
fix(datagen): make asset publication owner-run
anticorrelator 519a2c0
fix(datagen): reject invalid conversation structure
anticorrelator e1d464d
feat(datagen): replay rate schedule, backfill, and error injection
anticorrelator bf33c5d
feat(datagen): supplemental fault runs and bank merge
anticorrelator c745ce2
refactor(datagen): trim runtime verification to its floor
anticorrelator 282fa85
refactor(datagen): remove the cost plane and the batch lane
anticorrelator f5791e2
refactor(datagen): one shared serialization module for the sidecar sc…
anticorrelator 8e2daed
refactor(datagen): share the transcript hygiene names across the guards
anticorrelator a8dbe0c
refactor(datagen): rename bank to scenario and enforce judged outcome…
anticorrelator c6a9db3
refactor(datagen): scenario vocabulary and one owner per publish check
anticorrelator 13502a8
style(datagen): format test_codex_exec.py
anticorrelator ff9f5af
refactor(datagen): default the destination project to phoenix-datagen
anticorrelator a147b1c
refactor(datagen): zero-config replay with bundled or sole published …
anticorrelator cacf220
refactor(datagen): drop the seven session-shape tuning flags
anticorrelator 076c845
refactor(datagen): remove backfill, rate schedules, and the anomaly m…
anticorrelator 2f9cec6
Relax datagen replay validation and cache checks
anticorrelator 2b80ccc
Trim datagen generation checks and tests
anticorrelator 211aa17
Flatten datagen's published banks into a single corpus
anticorrelator 8b0880b
Trim the datagen replayer to its live paths
anticorrelator a8f3656
feat(datagen): simplify corpus archive pipeline
anticorrelator 1a2e58b
refactor(datagen): simplify trace replay
anticorrelator 585b13a
feat(datagen): replace generation runs with recorder fixtures
anticorrelator e8df019
refactor(datagen): record archetypes from fixed fixtures
anticorrelator b539c4f
refactor(datagen): align deployment with corpus replay
anticorrelator 77d583c
fix(datagen): satisfy repository type checks
anticorrelator 374c4d7
feat(datagen): add recorder condition materialization
anticorrelator 7af90dc
feat(datagen): add conditioned live recording lane
anticorrelator 6e6f676
fix(datagen): skip llama-index recorder test when instrumenter is absent
anticorrelator b9021f4
fix(datagen): skip guardrail recorder test when framework is absent
anticorrelator e548e13
test(datagen): trim suite to one happy path per surface
anticorrelator bed50ae
feat(datagen): add iterative coding tool traces
anticorrelator 4453d11
feat(datagen): enrich authored corpus inputs
anticorrelator 58fdefb
feat(datagen): report corpus depth statistics
anticorrelator 023cb18
feat(datagen): simulate live chat follow-up users
anticorrelator 095c6c3
fix(datagen): suppress simulated user spans
anticorrelator 90bee3c
fix(datagen): resolve luna recorder model
anticorrelator ce47250
fix(datagen): configure luna tool calls
anticorrelator 0dc7ac4
feat(datagen): vary simulated user dispositions
anticorrelator 53d8791
feat(datagen): add manual agent phase spans
anticorrelator 4982309
Weight replay session sampling by fragment count
anticorrelator 7c35481
Add a fat-tail slow-span outlier to replay jitter
anticorrelator 3566548
docs: replace internal vocabulary with plain terms
anticorrelator caaa985
Fix CI: formatting, redundant cast, and datagen script type checking
anticorrelator 27b1348
Fix datagen container start commands for the distroless image
anticorrelator f3b7e26
Prefix replayed session ids with their domain
anticorrelator 7f53499
Give each archetype its own session-length profile
anticorrelator bdec7ab
Steer conversation length organically and diversify coding seeds
anticorrelator 5d519fe
Let chat sessions chain a few whole conversations
anticorrelator 738000a
Apply ruff formatting to datagen recorder and test
anticorrelator 1cad4d0
Vary chat conversation openings per live run
anticorrelator 17e561d
Merge remote-tracking branch 'origin/main' into dustin/data-generatio…
anticorrelator 5e2e4d1
Restore scripts/ in the unit-test checkout and pin the chat test opening
anticorrelator cc7225d
Scope the scripted coding-agent test to fixtures with scripted episodes
anticorrelator f224791
Format the coding-agent test selection
anticorrelator 57dfd31
Move datagen tooling tests out of the unit suite
anticorrelator c1d3edf
Narrow this PR to the datagen replayer runtime
anticorrelator 6bba085
fix(ci): cap pydantic-ai-slim below 2.34 in unit test requirements
anticorrelator 1ea2018
Keep jittered token totals consistent when one component is missing
anticorrelator 4055fd6
Merge remote-tracking branch 'origin/main' into dustin/data-generatio…
anticorrelator e35a25d
Drop the pydantic-ai-slim unit-test cap after the vendored re-sync
anticorrelator d5a070b
Inline PHOENIX_CLIENT_HEADERS parsing in the datagen command
anticorrelator e751dd3
Mark datagen as internal tooling and move it under experimental
anticorrelator File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Replay recorded OpenInference traces into a Phoenix collector.""" | ||
|
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", | ||
| ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.