syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/tracer.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Body-free OTLP trace export for Tracegraph files and Phoenix streaming.
|
|
2
|
+
|
|
3
|
+
A **retrospective recorder**: strategies append completed :class:`SpanRecord`\\ s
|
|
4
|
+
right after each fan-out/attempt returns (the shared-loop boundary where memory
|
|
5
|
+
I/O already sits), never live span context managers — a live tracer would have
|
|
6
|
+
to penetrate the agent layer and break agent purity. The approximation is safe
|
|
7
|
+
by tracegraph's design: a span's ``start`` is derived as ``end −
|
|
8
|
+
duration_seconds`` and timestamps are never promoted to causality there (they
|
|
9
|
+
are only a prerank tiebreak); causality rides ``graph.node.parent_id`` (the one
|
|
10
|
+
logical cause) and span ``links`` (additional consumed causes, fan-in).
|
|
11
|
+
|
|
12
|
+
The emitted document is pinned byte-level by tracegraph's T1 generators
|
|
13
|
+
(``examples/syncmill_otlp_traces.py`` + the goldens under
|
|
14
|
+
``tests/fixtures/syncmill/``): protobuf-JSON attribute encoding (``intValue``
|
|
15
|
+
carries the int64 as a *string*; bool checked before int — bool is an int
|
|
16
|
+
subclass), span ``name`` as a stable structural label (agent ids / phases /
|
|
17
|
+
indices — never uuids/timestamps, or every tracegraph ``diff`` reads as
|
|
18
|
+
divergence), flat ``parentSpanId`` containment under the run root, and error
|
|
19
|
+
states as OTLP ``status`` with a bounded message (``timeout`` |
|
|
20
|
+
``exit_code=N`` | ``gate: <summary>`` — never raw agent output). tracegraph
|
|
21
|
+
consumes exactly three attributes (``openinference.span.kind``,
|
|
22
|
+
``graph.node.id``, ``graph.node.parent_id``); everything syncmill-specific is
|
|
23
|
+
additive ``syncmill.*`` from a hard allowlist the flush enforces (prompts,
|
|
24
|
+
output, patch bodies, and file paths are forbidden by contract).
|
|
25
|
+
|
|
26
|
+
``OTLPSpanAdapter`` raises on dangling parents/duplicate spanIds/partial
|
|
27
|
+
documents, so the canonical file exporter buffers everything and lands the
|
|
28
|
+
file with one atomic write (temp + ``os.replace``, the breaker/board recipe).
|
|
29
|
+
The optional Phoenix sink tees the same completed records through a bounded
|
|
30
|
+
background batcher; it never replaces the file. Everything degrades (the
|
|
31
|
+
memory idiom): observability failures never abort orchestration, and
|
|
32
|
+
:func:`build_tracer` returns ``None`` when disabled so the off path is
|
|
33
|
+
byte-identical to before.
|
|
34
|
+
|
|
35
|
+
A flat top-level module (gateway/monitor/session/board/report precedent).
|
|
36
|
+
Imports ``config``, plus one constant from ``report`` — the run-dir ownership
|
|
37
|
+
marker, which both producers must write identically (``report`` never imports
|
|
38
|
+
back, so the edge is acyclic). ``strategies/base`` imports the record/Protocol
|
|
39
|
+
from here, keeping the graph acyclic in that direction too.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import contextlib
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import secrets
|
|
50
|
+
import tempfile
|
|
51
|
+
import time
|
|
52
|
+
from collections.abc import Callable, Mapping
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Protocol, runtime_checkable
|
|
56
|
+
|
|
57
|
+
from syncmill.config import TraceConfig, normalize_phoenix_endpoint
|
|
58
|
+
|
|
59
|
+
# One constant, not the module: retention owns the run-dir marker, and both
|
|
60
|
+
# producers must write the same one. (report does not import tracer — the
|
|
61
|
+
# Supervisor wires them together — so this edge stays acyclic.)
|
|
62
|
+
from syncmill.report import RUN_DIR_SENTINEL
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
TRACE_FILE = "trace.otlp.json"
|
|
67
|
+
SCHEMA_VERSION = 1 # int — the ecosystem-wide contract version
|
|
68
|
+
|
|
69
|
+
# Bare-suffix mirror of tracegraph's SYNCMILL_ALLOWLIST (T1 generators). The
|
|
70
|
+
# flush prefixes ``syncmill.`` and drops anything outside this set, so a span
|
|
71
|
+
# builder can never leak a body-shaped attribute into the artifact.
|
|
72
|
+
_ALLOWLIST = frozenset(
|
|
73
|
+
{
|
|
74
|
+
"schema_version",
|
|
75
|
+
"run_id",
|
|
76
|
+
"strategy",
|
|
77
|
+
"phase",
|
|
78
|
+
"agent_id",
|
|
79
|
+
"attempt",
|
|
80
|
+
"round",
|
|
81
|
+
"status",
|
|
82
|
+
"exit_code",
|
|
83
|
+
"gate.passed",
|
|
84
|
+
"worktree_slot",
|
|
85
|
+
"winner",
|
|
86
|
+
"files_changed_count",
|
|
87
|
+
"tool.key",
|
|
88
|
+
"tool.sequence",
|
|
89
|
+
"telemetry.source",
|
|
90
|
+
"telemetry.complete",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# The three attribute keys tracegraph actually consumes (OTLPSpanAdapter).
|
|
95
|
+
_KIND_KEY = "openinference.span.kind"
|
|
96
|
+
_NODE_KEY = "graph.node.id"
|
|
97
|
+
_PARENT_KEY = "graph.node.parent_id"
|
|
98
|
+
|
|
99
|
+
_ROOT_NODE = "run"
|
|
100
|
+
|
|
101
|
+
# Defensive upper bound on an OTLP status message. Messages come only from our
|
|
102
|
+
# own bounded builders ("timeout" | "exit_code=N" | "gate: <summary>"), but a
|
|
103
|
+
# gate summary interpolates operator-configured command strings, so cap the
|
|
104
|
+
# length at flush to keep a pathological config out of the artifact.
|
|
105
|
+
_MAX_STATUS_MESSAGE = 200
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ToolgraphTraceEvidence:
|
|
110
|
+
"""Body-free Toolgraph decision reference understood by Tracegraph."""
|
|
111
|
+
|
|
112
|
+
artifact_digest: str
|
|
113
|
+
graph_generation: int
|
|
114
|
+
verdict: str
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", self.artifact_digest):
|
|
118
|
+
raise ValueError("invalid Toolgraph preflight artifact digest")
|
|
119
|
+
if (
|
|
120
|
+
isinstance(self.graph_generation, bool)
|
|
121
|
+
or not isinstance(self.graph_generation, int)
|
|
122
|
+
or self.graph_generation < 0
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("invalid Toolgraph graph generation")
|
|
125
|
+
if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", self.verdict):
|
|
126
|
+
raise ValueError("invalid Toolgraph preflight verdict")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class SpanRecord:
|
|
131
|
+
"""One completed span, recorded retrospectively by a strategy.
|
|
132
|
+
|
|
133
|
+
``name`` is the stable diff/AHU label (agent ids/phases/indices only —
|
|
134
|
+
never uuids); ``node_id`` must be unique within the trace; ``node_parent``
|
|
135
|
+
is the ONE logical cause; ``links`` are additional consumed causes
|
|
136
|
+
(node_ids, resolved to spanIds at flush). ``attrs`` carries bare-suffix
|
|
137
|
+
``syncmill.*`` keys (e.g. ``"phase"``, ``"gate.passed"``) — the tracer
|
|
138
|
+
prefixes and allowlist-filters them.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
name: str
|
|
142
|
+
kind: str # OpenInference kind, e.g. AGENT/CHAIN/TOOL/RETRIEVER/GUARDRAIL
|
|
143
|
+
node_id: str
|
|
144
|
+
node_parent: str | None
|
|
145
|
+
links: tuple[str, ...] = ()
|
|
146
|
+
start: float = 0.0 # unix seconds
|
|
147
|
+
end: float = 0.0
|
|
148
|
+
status: str = "completed" # completed | failed | timeout | cancelled
|
|
149
|
+
error: str | None = None # bounded summary — never raw output
|
|
150
|
+
attrs: Mapping[str, str | int | bool] = field(default_factory=dict)
|
|
151
|
+
toolgraph_evidence: ToolgraphTraceEvidence | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_span(
|
|
155
|
+
*,
|
|
156
|
+
strategy: str,
|
|
157
|
+
status: str,
|
|
158
|
+
start: float,
|
|
159
|
+
end: float,
|
|
160
|
+
toolgraph_evidence: ToolgraphTraceEvidence | None = None,
|
|
161
|
+
) -> SpanRecord:
|
|
162
|
+
"""The ``syncmill.run`` root span — recorded by the Supervisor in its
|
|
163
|
+
``finally``, once the final status is known."""
|
|
164
|
+
return SpanRecord(
|
|
165
|
+
name="syncmill.run",
|
|
166
|
+
kind="CHAIN",
|
|
167
|
+
node_id=_ROOT_NODE,
|
|
168
|
+
node_parent=None,
|
|
169
|
+
start=start,
|
|
170
|
+
end=end,
|
|
171
|
+
status=status,
|
|
172
|
+
attrs={"strategy": strategy, "agent_id": "supervisor", "status": status},
|
|
173
|
+
toolgraph_evidence=toolgraph_evidence,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@runtime_checkable
|
|
178
|
+
class Tracer(Protocol):
|
|
179
|
+
"""The recorder seam strategies receive (``Strategy.execute(tracer=)``).
|
|
180
|
+
|
|
181
|
+
``record`` must never raise out (the strategy is mid-orchestration);
|
|
182
|
+
``flush`` writes the artifact and returns its path, or ``None`` + a
|
|
183
|
+
warning on failure — callers own the degrade.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
trace_id: str
|
|
187
|
+
|
|
188
|
+
def record(self, span: SpanRecord) -> None: ...
|
|
189
|
+
|
|
190
|
+
def flush(self) -> Path | None: ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _attr(key: str, value: str | int | bool) -> dict:
|
|
194
|
+
"""Protobuf-JSON attribute encoding (byte-contract with the T1 goldens)."""
|
|
195
|
+
if isinstance(value, bool): # bool first — bool is an int subclass
|
|
196
|
+
wrapped: dict = {"boolValue": value}
|
|
197
|
+
elif isinstance(value, int):
|
|
198
|
+
wrapped = {"intValue": str(value)} # protobuf-JSON encodes int64 as string
|
|
199
|
+
else:
|
|
200
|
+
wrapped = {"stringValue": str(value)}
|
|
201
|
+
return {"key": key, "value": wrapped}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _span_attributes(
|
|
205
|
+
span: SpanRecord,
|
|
206
|
+
run_id: str,
|
|
207
|
+
*,
|
|
208
|
+
warn_on_drop: bool = True,
|
|
209
|
+
) -> dict[str, str | int | bool]:
|
|
210
|
+
"""Return the single body-free attribute view shared by every sink."""
|
|
211
|
+
attributes: dict[str, str | int | bool] = {
|
|
212
|
+
_KIND_KEY: span.kind,
|
|
213
|
+
_NODE_KEY: span.node_id,
|
|
214
|
+
}
|
|
215
|
+
if span.node_parent is not None:
|
|
216
|
+
attributes[_PARENT_KEY] = span.node_parent
|
|
217
|
+
attributes["syncmill.schema_version"] = SCHEMA_VERSION
|
|
218
|
+
attributes["syncmill.run_id"] = run_id
|
|
219
|
+
for key, value in span.attrs.items():
|
|
220
|
+
if key in {"schema_version", "run_id"}:
|
|
221
|
+
continue
|
|
222
|
+
if key not in _ALLOWLIST:
|
|
223
|
+
if warn_on_drop:
|
|
224
|
+
logger.warning(
|
|
225
|
+
"trace attr %r is outside the syncmill.* allowlist; dropped",
|
|
226
|
+
key,
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
attributes[f"syncmill.{key}"] = value
|
|
230
|
+
evidence = span.toolgraph_evidence
|
|
231
|
+
if evidence is not None:
|
|
232
|
+
attributes.update(
|
|
233
|
+
{
|
|
234
|
+
"toolgraph.preflight.artifact_digest": evidence.artifact_digest,
|
|
235
|
+
"toolgraph.graph_generation": evidence.graph_generation,
|
|
236
|
+
"toolgraph.preflight.verdict": evidence.verdict,
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
return attributes
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class FileTracer:
|
|
243
|
+
"""Buffers span records; ``flush()`` emits one OTLP protobuf-JSON document.
|
|
244
|
+
|
|
245
|
+
``id_factory`` is the deterministic test seam — it supplies the 32-hex
|
|
246
|
+
trace id first, then one 16-hex span id per span (in recording order).
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
run_id: str,
|
|
252
|
+
out_dir: Path,
|
|
253
|
+
*,
|
|
254
|
+
id_factory: Callable[[int], str] | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
self._run_id = run_id
|
|
257
|
+
self._out_dir = out_dir
|
|
258
|
+
self._ids = id_factory or (lambda nbytes: secrets.token_hex(nbytes))
|
|
259
|
+
self.trace_id: str = self._ids(16)
|
|
260
|
+
self._spans: list[SpanRecord] = []
|
|
261
|
+
self.degraded = False
|
|
262
|
+
self.last_error: str | None = None
|
|
263
|
+
|
|
264
|
+
def record(self, span: SpanRecord) -> None:
|
|
265
|
+
"""Append one completed span. Never raises out — recording is
|
|
266
|
+
observability, and a broken tracer must not abort orchestration."""
|
|
267
|
+
try:
|
|
268
|
+
self._spans.append(span)
|
|
269
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
270
|
+
self._degrade(f"could not record span: {exc}")
|
|
271
|
+
|
|
272
|
+
def flush(self) -> Path | None:
|
|
273
|
+
"""Resolve ids, build the document, and atomically write the artifact.
|
|
274
|
+
|
|
275
|
+
All-or-nothing by contract: tracegraph's ``OTLPSpanAdapter`` raises on
|
|
276
|
+
partial documents, dangling ``parentSpanId``, and duplicate spanIds —
|
|
277
|
+
so ids are validated here and the write is temp + ``os.replace``.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
return self._write(self._build_document())
|
|
281
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
282
|
+
self._degrade(f"trace flush failed: {exc}")
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _build_document(self) -> dict:
|
|
288
|
+
span_ids: dict[str, str] = {}
|
|
289
|
+
for span in self._spans:
|
|
290
|
+
if span.node_id in span_ids:
|
|
291
|
+
raise ValueError(f"duplicate span node_id: {span.node_id!r}")
|
|
292
|
+
span_ids[span.node_id] = self._ids(8)
|
|
293
|
+
root_span_id = span_ids.get(_ROOT_NODE)
|
|
294
|
+
out = []
|
|
295
|
+
for span in self._spans:
|
|
296
|
+
attrs = []
|
|
297
|
+
if span.node_parent is not None:
|
|
298
|
+
# An unresolved logical parent makes the whole document
|
|
299
|
+
# rejectable (tracegraph's adapter raises on it) — fail the
|
|
300
|
+
# flush all-or-nothing rather than write a lossy artifact and
|
|
301
|
+
# report it "ok".
|
|
302
|
+
if span.node_parent not in span_ids:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"span {span.node_id!r} has unresolved "
|
|
305
|
+
f"graph.node.parent_id {span.node_parent!r}"
|
|
306
|
+
)
|
|
307
|
+
attrs.extend(
|
|
308
|
+
_attr(key, value)
|
|
309
|
+
for key, value in _span_attributes(span, self._run_id).items()
|
|
310
|
+
)
|
|
311
|
+
doc: dict = {
|
|
312
|
+
"traceId": self.trace_id,
|
|
313
|
+
"spanId": span_ids[span.node_id],
|
|
314
|
+
"name": span.name,
|
|
315
|
+
"startTimeUnixNano": str(int(span.start * 1e9)),
|
|
316
|
+
"endTimeUnixNano": str(int(span.end * 1e9)),
|
|
317
|
+
"attributes": attrs,
|
|
318
|
+
}
|
|
319
|
+
# Flat containment: every non-root span parents on the run root
|
|
320
|
+
# (causality rides graph.node.parent_id, never parentSpanId). A
|
|
321
|
+
# missing root must not dangle — the adapter raises on that.
|
|
322
|
+
if span.node_id != _ROOT_NODE and root_span_id is not None:
|
|
323
|
+
doc["parentSpanId"] = root_span_id
|
|
324
|
+
if span.status in {"failed", "timeout", "cancelled"}:
|
|
325
|
+
message = span.error or span.status
|
|
326
|
+
doc["status"] = {
|
|
327
|
+
"code": "STATUS_CODE_ERROR",
|
|
328
|
+
"message": message[:_MAX_STATUS_MESSAGE],
|
|
329
|
+
}
|
|
330
|
+
links = []
|
|
331
|
+
for target in span.links:
|
|
332
|
+
span_id = span_ids.get(target)
|
|
333
|
+
if span_id is None:
|
|
334
|
+
# A dangling link would make the adapter reject the whole
|
|
335
|
+
# document — fail the flush all-or-nothing rather than emit
|
|
336
|
+
# a silently-lossy trace (the degrade path, same as an
|
|
337
|
+
# unresolved logical parent above).
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"span {span.node_id!r} links to unrecorded node {target!r}"
|
|
340
|
+
)
|
|
341
|
+
links.append({"traceId": self.trace_id, "spanId": span_id})
|
|
342
|
+
if links:
|
|
343
|
+
doc["links"] = links
|
|
344
|
+
out.append(doc)
|
|
345
|
+
return {"resourceSpans": [{"scopeSpans": [{"spans": out}]}]}
|
|
346
|
+
|
|
347
|
+
def _write(self, document: dict) -> Path:
|
|
348
|
+
self._out_dir.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
# A trace-only run (reporting off) creates this dir itself, and the same
|
|
350
|
+
# retention sweep prunes it — so it has to carry the same ownership
|
|
351
|
+
# marker the reporter writes, or it could never be collected.
|
|
352
|
+
with contextlib.suppress(OSError):
|
|
353
|
+
(self._out_dir / RUN_DIR_SENTINEL).write_text("")
|
|
354
|
+
path = self._out_dir / TRACE_FILE
|
|
355
|
+
fd, tmp = tempfile.mkstemp(dir=str(self._out_dir), suffix=".tmp")
|
|
356
|
+
try:
|
|
357
|
+
with os.fdopen(fd, "w") as f:
|
|
358
|
+
json.dump(document, f)
|
|
359
|
+
os.replace(tmp, path)
|
|
360
|
+
except BaseException:
|
|
361
|
+
try:
|
|
362
|
+
os.unlink(tmp)
|
|
363
|
+
except OSError:
|
|
364
|
+
pass
|
|
365
|
+
raise
|
|
366
|
+
return path
|
|
367
|
+
|
|
368
|
+
def _degrade(self, msg: str) -> None:
|
|
369
|
+
self.degraded = True
|
|
370
|
+
self.last_error = msg
|
|
371
|
+
logger.warning("tracer degraded: %s", msg)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class TeeTracer:
|
|
375
|
+
"""Durable file trace plus a best-effort Phoenix streaming sidecar."""
|
|
376
|
+
|
|
377
|
+
def __init__(
|
|
378
|
+
self,
|
|
379
|
+
file_tracer: FileTracer,
|
|
380
|
+
stream: object | None,
|
|
381
|
+
*,
|
|
382
|
+
missing_stream_status: str = "error",
|
|
383
|
+
) -> None:
|
|
384
|
+
self._file = file_tracer
|
|
385
|
+
self._stream = stream
|
|
386
|
+
self._missing_stream_status = missing_stream_status
|
|
387
|
+
self.trace_id = file_tracer.trace_id
|
|
388
|
+
self._recorded = 0
|
|
389
|
+
self.stream_receipt: dict | None = None
|
|
390
|
+
|
|
391
|
+
def record(self, span: SpanRecord) -> None:
|
|
392
|
+
self._file.record(span)
|
|
393
|
+
self._recorded += 1
|
|
394
|
+
if self._stream is None:
|
|
395
|
+
return
|
|
396
|
+
try:
|
|
397
|
+
self._stream.record(span) # type: ignore[attr-defined]
|
|
398
|
+
except Exception:
|
|
399
|
+
self._stream = None
|
|
400
|
+
logger.warning(
|
|
401
|
+
"Phoenix stream record failed; continuing with file trace",
|
|
402
|
+
exc_info=True,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def flush(self) -> Path | None:
|
|
406
|
+
# The durable artifact is canonical and must not wait on the network.
|
|
407
|
+
path = self._file.flush()
|
|
408
|
+
if self._stream is None:
|
|
409
|
+
self.stream_receipt = {
|
|
410
|
+
"backend": "phoenix",
|
|
411
|
+
"protocol": "otlp-http/protobuf",
|
|
412
|
+
"status": self._missing_stream_status,
|
|
413
|
+
"exported_span_count": 0,
|
|
414
|
+
"dropped_span_count": self._recorded,
|
|
415
|
+
}
|
|
416
|
+
return path
|
|
417
|
+
try:
|
|
418
|
+
self.stream_receipt = self._stream.flush() # type: ignore[attr-defined]
|
|
419
|
+
except Exception:
|
|
420
|
+
logger.warning(
|
|
421
|
+
"Phoenix stream flush failed; continuing with file trace", exc_info=True
|
|
422
|
+
)
|
|
423
|
+
self.stream_receipt = {
|
|
424
|
+
"backend": "phoenix",
|
|
425
|
+
"protocol": "otlp-http/protobuf",
|
|
426
|
+
"status": "error",
|
|
427
|
+
"exported_span_count": 0,
|
|
428
|
+
"dropped_span_count": self._recorded,
|
|
429
|
+
}
|
|
430
|
+
return path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def flush_trace(tracer: Tracer) -> dict:
|
|
434
|
+
"""Flush + digest into the envelope ``trace`` block. Never raises.
|
|
435
|
+
|
|
436
|
+
``{"status": "ok", protocol, trace_id, artifact, digest}`` on success —
|
|
437
|
+
the exact conditional-required set of the envelope schema's ``traceBlock``
|
|
438
|
+
— or ``{"status": "error"}`` when the flush or digest failed.
|
|
439
|
+
"""
|
|
440
|
+
# Local import: report.py imports config/models/monitor only, so this is
|
|
441
|
+
# acyclic — reusing sha256_digest keeps the digest form from drifting.
|
|
442
|
+
from syncmill.report import sha256_digest
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
path = tracer.flush()
|
|
446
|
+
if path is None:
|
|
447
|
+
return {"status": "error"}
|
|
448
|
+
block = {
|
|
449
|
+
"status": "ok",
|
|
450
|
+
"protocol": "otlp-json-file",
|
|
451
|
+
"trace_id": tracer.trace_id,
|
|
452
|
+
"artifact": TRACE_FILE, # relative to the envelope's directory
|
|
453
|
+
"digest": sha256_digest(path.read_bytes()),
|
|
454
|
+
}
|
|
455
|
+
stream = getattr(tracer, "stream_receipt", None)
|
|
456
|
+
if isinstance(stream, dict):
|
|
457
|
+
block["stream"] = stream
|
|
458
|
+
return block
|
|
459
|
+
except Exception as exc:
|
|
460
|
+
logger.warning("trace block build failed: %s", exc)
|
|
461
|
+
return {"status": "error"}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _phoenix_endpoint(cfg: TraceConfig) -> str:
|
|
465
|
+
raw = (
|
|
466
|
+
cfg.endpoint
|
|
467
|
+
or os.environ.get("PHOENIX_COLLECTOR_ENDPOINT")
|
|
468
|
+
or "http://127.0.0.1:6006/v1/traces"
|
|
469
|
+
)
|
|
470
|
+
return normalize_phoenix_endpoint(raw)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def build_tracer(
|
|
474
|
+
cfg: TraceConfig,
|
|
475
|
+
*,
|
|
476
|
+
run_id: str,
|
|
477
|
+
fallback_dir: str,
|
|
478
|
+
run_start: float | None = None,
|
|
479
|
+
) -> Tracer | None:
|
|
480
|
+
"""The tracer, or ``None`` when tracing is disabled (the default) — the
|
|
481
|
+
``build_gate``/``build_breaker`` pattern: the disabled path records nothing,
|
|
482
|
+
touches no filesystem, and behavior is byte-identical to before.
|
|
483
|
+
|
|
484
|
+
``fallback_dir`` is the report ``runs_dir`` — ``dir=""`` (the default)
|
|
485
|
+
means "beside the run envelope", and only the caller sees ``ReportConfig``.
|
|
486
|
+
Body inclusion remains warn-only here (once per run), rather than in a
|
|
487
|
+
config validator that would fire on every construction.
|
|
488
|
+
"""
|
|
489
|
+
if not cfg.enabled:
|
|
490
|
+
return None
|
|
491
|
+
if cfg.exporter not in {"file", "otlp-http"}:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"unknown trace exporter %r; using the file exporter", cfg.exporter
|
|
494
|
+
)
|
|
495
|
+
if cfg.include_output:
|
|
496
|
+
logger.warning(
|
|
497
|
+
"trace include_output is not supported in v1 (spans are body-free "
|
|
498
|
+
"by contract); ignoring"
|
|
499
|
+
)
|
|
500
|
+
try:
|
|
501
|
+
out_dir = Path(cfg.dir or fallback_dir).expanduser() / run_id
|
|
502
|
+
except RuntimeError as exc: # unresolvable ~ (no-HOME cron/systemd)
|
|
503
|
+
logger.warning("trace dir could not be resolved: %s; tracing disabled", exc)
|
|
504
|
+
return None
|
|
505
|
+
file_tracer = FileTracer(run_id, out_dir)
|
|
506
|
+
if cfg.exporter != "otlp-http":
|
|
507
|
+
return file_tracer
|
|
508
|
+
|
|
509
|
+
stream = None
|
|
510
|
+
missing_stream_status = "error"
|
|
511
|
+
try:
|
|
512
|
+
from syncmill.phoenix_exporter import PhoenixSpanSink
|
|
513
|
+
|
|
514
|
+
project_name = (
|
|
515
|
+
cfg.project_name.strip()
|
|
516
|
+
or os.environ.get("PHOENIX_PROJECT_NAME", "").strip()
|
|
517
|
+
or "syncmill"
|
|
518
|
+
)
|
|
519
|
+
stream = PhoenixSpanSink(
|
|
520
|
+
run_id=run_id,
|
|
521
|
+
trace_id=file_tracer.trace_id,
|
|
522
|
+
run_start=run_start if run_start is not None else time.time(),
|
|
523
|
+
endpoint=_phoenix_endpoint(cfg),
|
|
524
|
+
project_name=project_name,
|
|
525
|
+
batch_delay_seconds=cfg.batch_delay_seconds,
|
|
526
|
+
timeout_seconds=cfg.timeout_seconds,
|
|
527
|
+
attribute_factory=lambda span: _span_attributes(
|
|
528
|
+
span,
|
|
529
|
+
run_id,
|
|
530
|
+
warn_on_drop=False,
|
|
531
|
+
),
|
|
532
|
+
)
|
|
533
|
+
except ModuleNotFoundError:
|
|
534
|
+
missing_stream_status = "unavailable"
|
|
535
|
+
logger.warning(
|
|
536
|
+
"Phoenix trace streaming requires syncmill[phoenix] "
|
|
537
|
+
"(or `uv sync --extra phoenix`); continuing with the file trace"
|
|
538
|
+
)
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
logger.warning(
|
|
541
|
+
"Phoenix trace streaming could not start: %s; continuing with the file trace",
|
|
542
|
+
exc,
|
|
543
|
+
)
|
|
544
|
+
return TeeTracer(
|
|
545
|
+
file_tracer,
|
|
546
|
+
stream,
|
|
547
|
+
missing_stream_status=missing_stream_status,
|
|
548
|
+
)
|