agent-observability-trace-cli 0.1.2__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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OTLP exporter — sends spans to an OpenTelemetry collector endpoint.
|
|
3
|
+
|
|
4
|
+
Requires: pip install opentelemetry-exporter-otlp-proto-grpc
|
|
5
|
+
|
|
6
|
+
This exporter converts agent-trace Span objects to OTLP format and
|
|
7
|
+
sends them via gRPC. Use this to integrate with Jaeger, Grafana Tempo,
|
|
8
|
+
or any OTLP-compatible backend.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from agent_trace.core.span import SpanStatus
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from agent_trace import Span, Trace
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"OTLPExporter",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_OTLP_INSTALL_HINT = (
|
|
28
|
+
"The OTLP exporter requires the OpenTelemetry gRPC package.\n"
|
|
29
|
+
"Install it with:\n\n"
|
|
30
|
+
" pip install opentelemetry-exporter-otlp-proto-grpc\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Nanoseconds per second — OTLP uses int nanoseconds for timestamps
|
|
34
|
+
_NS_PER_SEC: int = 1_000_000_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OTLPExporter:
|
|
38
|
+
"""Export a :class:`~agent_trace.Trace` to an OTLP-compatible backend.
|
|
39
|
+
|
|
40
|
+
Converts agent-trace spans to OpenTelemetry ``ResourceSpan`` format and
|
|
41
|
+
ships them to *endpoint* via gRPC.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
endpoint:
|
|
46
|
+
gRPC endpoint of the OpenTelemetry collector, e.g.
|
|
47
|
+
``"http://localhost:4317"`` (the default).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, endpoint: str = "http://localhost:4317") -> None:
|
|
51
|
+
self.endpoint: str = endpoint
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Public API
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def export(self, trace: Trace) -> None:
|
|
58
|
+
"""Send all spans in *trace* to the configured OTLP endpoint."""
|
|
59
|
+
try:
|
|
60
|
+
from opentelemetry import context as otel_context
|
|
61
|
+
from opentelemetry import trace as otel_trace
|
|
62
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
63
|
+
OTLPSpanExporter,
|
|
64
|
+
)
|
|
65
|
+
from opentelemetry.sdk.resources import Resource
|
|
66
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
67
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
68
|
+
from opentelemetry.trace import (
|
|
69
|
+
NonRecordingSpan,
|
|
70
|
+
SpanContext,
|
|
71
|
+
StatusCode,
|
|
72
|
+
TraceFlags,
|
|
73
|
+
)
|
|
74
|
+
except ImportError as exc:
|
|
75
|
+
raise ImportError(_OTLP_INSTALL_HINT) from exc
|
|
76
|
+
|
|
77
|
+
# Build the status map once per export call rather than per span.
|
|
78
|
+
status_map: dict[SpanStatus, Any] = {
|
|
79
|
+
SpanStatus.OK: StatusCode.OK,
|
|
80
|
+
SpanStatus.ERROR: StatusCode.ERROR,
|
|
81
|
+
SpanStatus.UNSET: StatusCode.UNSET,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
service_name = str(trace.metadata.get("name", "agent-trace"))
|
|
85
|
+
exporter = OTLPSpanExporter(endpoint=self.endpoint)
|
|
86
|
+
resource = Resource.create({"service.name": service_name})
|
|
87
|
+
provider = TracerProvider(resource=resource)
|
|
88
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
89
|
+
otel_tracer = provider.get_tracer("agent-trace")
|
|
90
|
+
failed = 0
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
for span in trace.spans:
|
|
94
|
+
otlp_data = self._span_to_otlp(span, status_map)
|
|
95
|
+
try:
|
|
96
|
+
trace_id_int = (
|
|
97
|
+
int(span.trace_id, 16)
|
|
98
|
+
if _is_hex(span.trace_id)
|
|
99
|
+
else hash(span.trace_id) & ((1 << 128) - 1)
|
|
100
|
+
)
|
|
101
|
+
parent_ctx = otel_context.get_current()
|
|
102
|
+
if span.parent_id is not None:
|
|
103
|
+
parent_span_id_int = (
|
|
104
|
+
int(span.parent_id, 16)
|
|
105
|
+
if _is_hex(span.parent_id)
|
|
106
|
+
else hash(span.parent_id) & ((1 << 64) - 1)
|
|
107
|
+
)
|
|
108
|
+
parent_span_context = SpanContext(
|
|
109
|
+
trace_id=trace_id_int,
|
|
110
|
+
span_id=parent_span_id_int,
|
|
111
|
+
is_remote=True,
|
|
112
|
+
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
113
|
+
)
|
|
114
|
+
parent_ctx = otel_trace.set_span_in_context(
|
|
115
|
+
NonRecordingSpan(parent_span_context)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
otel_span = otel_tracer.start_span(
|
|
119
|
+
span.name,
|
|
120
|
+
context=parent_ctx,
|
|
121
|
+
start_time=otlp_data["start_time_unix_nano"],
|
|
122
|
+
)
|
|
123
|
+
for k, v in span.attributes.items():
|
|
124
|
+
attr_v = v if isinstance(v, (bool, int, float, str)) else str(v)
|
|
125
|
+
otel_span.set_attribute(k, attr_v)
|
|
126
|
+
for event in span.events:
|
|
127
|
+
otel_span.add_event(
|
|
128
|
+
event.name,
|
|
129
|
+
attributes={k: str(v) for k, v in event.attributes.items()},
|
|
130
|
+
)
|
|
131
|
+
otel_span.set_status(otlp_data["status_code"])
|
|
132
|
+
otel_span.end(end_time=otlp_data["end_time_unix_nano"])
|
|
133
|
+
except Exception:
|
|
134
|
+
failed += 1
|
|
135
|
+
logger.debug(
|
|
136
|
+
"agent-trace: failed to export span %r to OTLP",
|
|
137
|
+
span.span_id,
|
|
138
|
+
exc_info=True,
|
|
139
|
+
)
|
|
140
|
+
finally:
|
|
141
|
+
try:
|
|
142
|
+
provider.shutdown()
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"agent-trace: OTLP provider shutdown failed", exc_info=True
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if failed:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"agent-trace: %d/%d span(s) failed to export to %s",
|
|
151
|
+
failed,
|
|
152
|
+
len(trace.spans),
|
|
153
|
+
self.endpoint,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _span_to_otlp(
|
|
157
|
+
self, span: Span, status_map: dict[SpanStatus, Any]
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Convert a :class:`~agent_trace.Span` to an OTLP-compatible dict.
|
|
160
|
+
|
|
161
|
+
The returned dict contains::
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"name": str,
|
|
165
|
+
"trace_id": str,
|
|
166
|
+
"span_id": str,
|
|
167
|
+
"parent_span_id": str | None,
|
|
168
|
+
"start_time_unix_nano": int,
|
|
169
|
+
"end_time_unix_nano": int | None,
|
|
170
|
+
"status_code": StatusCode,
|
|
171
|
+
"attributes": list[KeyValue],
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
end_ns: int | None = (
|
|
175
|
+
int(span.end_time * _NS_PER_SEC) if span.end_time is not None else None
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
"name": span.name,
|
|
179
|
+
"trace_id": span.trace_id,
|
|
180
|
+
"span_id": span.span_id,
|
|
181
|
+
"parent_span_id": span.parent_id,
|
|
182
|
+
"start_time_unix_nano": int(span.start_time * _NS_PER_SEC),
|
|
183
|
+
"end_time_unix_nano": end_ns,
|
|
184
|
+
"status_code": status_map.get(span.status, status_map[SpanStatus.UNSET]),
|
|
185
|
+
"attributes": [{"key": k, "value": v} for k, v in span.attributes.items()],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Helpers
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_hex(s: str) -> bool:
|
|
195
|
+
try:
|
|
196
|
+
int(s, 16)
|
|
197
|
+
return True
|
|
198
|
+
except ValueError:
|
|
199
|
+
return False
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Durable/remote fixture backend — so a recording survives a killed or swept
|
|
3
|
+
worker process.
|
|
4
|
+
|
|
5
|
+
Background
|
|
6
|
+
----------
|
|
7
|
+
|
|
8
|
+
Fixtures and trace files are written only to the invoking process's local
|
|
9
|
+
filesystem (``Tracer.start_trace()``'s ``run_dir``); the only non-local
|
|
10
|
+
exporter is OTLP, which carries span metadata but not HTTP request/response
|
|
11
|
+
bodies. On managed platforms (e.g. LangGraph Cloud — issue #7417), a worker
|
|
12
|
+
whose run gets swept/re-dispatched (or simply killed) has an ephemeral,
|
|
13
|
+
developer-inaccessible local filesystem — any fixture recorded there is
|
|
14
|
+
unrecoverable after the fact.
|
|
15
|
+
|
|
16
|
+
This module adds an object-store-backed ``RemoteFixtureBackend`` so a
|
|
17
|
+
recorded trace and its captured wire-level bodies persist independently of
|
|
18
|
+
the worker process that produced them:
|
|
19
|
+
|
|
20
|
+
- :class:`RemoteFixtureBackend` — the storage-agnostic protocol
|
|
21
|
+
(``put_bytes``/``get_bytes``/``list_keys``).
|
|
22
|
+
- :class:`LocalDirRemoteFixtureBackend` — writes to a second directory,
|
|
23
|
+
e.g. a network-mounted (NFS/EFS/SMB) path distinct from the worker's own
|
|
24
|
+
ephemeral local disk. A fully real, dependency-free backend on its own
|
|
25
|
+
for any deployment where a shared mount is available, and the reference
|
|
26
|
+
implementation the two cloud backends below are tested against.
|
|
27
|
+
- :class:`S3RemoteFixtureBackend` — lazy ``import boto3``; requires
|
|
28
|
+
``pip install boto3``.
|
|
29
|
+
- :class:`GCSRemoteFixtureBackend` — lazy
|
|
30
|
+
``import google.cloud.storage``; requires
|
|
31
|
+
``pip install google-cloud-storage``.
|
|
32
|
+
|
|
33
|
+
Usage — durable per-exchange sync while recording (survives the worker
|
|
34
|
+
being killed mid-run, not just at clean exit)::
|
|
35
|
+
|
|
36
|
+
from agent_trace.exporters.remote_fixture import (
|
|
37
|
+
LocalDirRemoteFixtureBackend,
|
|
38
|
+
remote_sync_callback,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
backend = LocalDirRemoteFixtureBackend(Path("/mnt/shared/agent-trace"))
|
|
42
|
+
with tracer.start_trace("my_graph", record=True,
|
|
43
|
+
remote_backend=backend) as trace:
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
Usage — one-shot sync/restore of an already-recorded run::
|
|
47
|
+
|
|
48
|
+
from agent_trace.exporters.remote_fixture import (
|
|
49
|
+
sync_run_to_remote,
|
|
50
|
+
restore_run_from_remote,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
sync_run_to_remote(run_dir, backend, run_id="run_abc123")
|
|
54
|
+
restored_dir = restore_run_from_remote(backend, run_id="run_abc123",
|
|
55
|
+
dest_dir=Path("/tmp/restored"))
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import json
|
|
61
|
+
import logging
|
|
62
|
+
from abc import ABC, abstractmethod
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"GCSRemoteFixtureBackend",
|
|
68
|
+
"LocalDirRemoteFixtureBackend",
|
|
69
|
+
"RemoteFixtureBackend",
|
|
70
|
+
"S3RemoteFixtureBackend",
|
|
71
|
+
"remote_sync_callback",
|
|
72
|
+
"restore_run_from_remote",
|
|
73
|
+
"sync_run_to_remote",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
_S3_INSTALL_HINT = (
|
|
79
|
+
"S3RemoteFixtureBackend requires boto3.\nInstall it with:\n\n"
|
|
80
|
+
" pip install boto3\n"
|
|
81
|
+
)
|
|
82
|
+
_GCS_INSTALL_HINT = (
|
|
83
|
+
"GCSRemoteFixtureBackend requires google-cloud-storage.\n"
|
|
84
|
+
"Install it with:\n\n"
|
|
85
|
+
" pip install google-cloud-storage\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RemoteFixtureBackend(ABC):
|
|
90
|
+
"""Storage-agnostic protocol for durably persisting fixture data
|
|
91
|
+
somewhere other than the worker process's own local filesystem."""
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
95
|
+
"""Durably store *data* under *key*, overwriting any existing value."""
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
99
|
+
"""Return the bytes stored under *key*, or None if absent."""
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
103
|
+
"""Return every stored key starting with *prefix*."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class LocalDirRemoteFixtureBackend(RemoteFixtureBackend):
|
|
107
|
+
"""Writes to a second directory — e.g. a network-mounted path distinct
|
|
108
|
+
from the worker's own ephemeral local disk. Real and fully functional
|
|
109
|
+
(not a mock/stub): a legitimate deployment choice on its own whenever a
|
|
110
|
+
shared mount (NFS/EFS/SMB) is available, and the backend the cloud
|
|
111
|
+
implementations below are tested against for correctness."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, root: Path) -> None:
|
|
114
|
+
self.root = Path(root)
|
|
115
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
def _path_for(self, key: str) -> Path:
|
|
118
|
+
base = self.root.resolve()
|
|
119
|
+
candidate = (base / key).resolve()
|
|
120
|
+
try:
|
|
121
|
+
candidate.relative_to(base)
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise ValueError(f"Invalid key {key!r}: path traversal detected") from None
|
|
124
|
+
return candidate
|
|
125
|
+
|
|
126
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
127
|
+
path = self._path_for(key)
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
path.write_bytes(data)
|
|
130
|
+
|
|
131
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
132
|
+
path = self._path_for(key)
|
|
133
|
+
if not path.exists():
|
|
134
|
+
return None
|
|
135
|
+
return path.read_bytes()
|
|
136
|
+
|
|
137
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
138
|
+
base = self.root.resolve()
|
|
139
|
+
prefix_path = self._path_for(prefix)
|
|
140
|
+
search_root = prefix_path if prefix_path.is_dir() else prefix_path.parent
|
|
141
|
+
if not search_root.exists():
|
|
142
|
+
return []
|
|
143
|
+
return sorted(
|
|
144
|
+
str(p.relative_to(base))
|
|
145
|
+
for p in search_root.rglob("*")
|
|
146
|
+
if p.is_file() and str(p.relative_to(base)).startswith(prefix)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class S3RemoteFixtureBackend(RemoteFixtureBackend):
|
|
151
|
+
"""S3-backed remote fixture store. ``boto3`` is imported lazily — this
|
|
152
|
+
class can be referenced (e.g. for isinstance checks) even when boto3
|
|
153
|
+
isn't installed; only constructing an instance requires it."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, bucket: str, prefix: str = "", client: Any = None) -> None:
|
|
156
|
+
self.bucket = bucket
|
|
157
|
+
self.prefix = prefix.rstrip("/")
|
|
158
|
+
self._client = client or self._make_client()
|
|
159
|
+
|
|
160
|
+
def _make_client(self) -> Any:
|
|
161
|
+
try:
|
|
162
|
+
import boto3
|
|
163
|
+
except ImportError as exc:
|
|
164
|
+
raise ImportError(_S3_INSTALL_HINT) from exc
|
|
165
|
+
return boto3.client("s3")
|
|
166
|
+
|
|
167
|
+
def _full_key(self, key: str) -> str:
|
|
168
|
+
return f"{self.prefix}/{key}" if self.prefix else key
|
|
169
|
+
|
|
170
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
171
|
+
self._client.put_object(Bucket=self.bucket, Key=self._full_key(key), Body=data)
|
|
172
|
+
|
|
173
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
174
|
+
try:
|
|
175
|
+
response = self._client.get_object(
|
|
176
|
+
Bucket=self.bucket, Key=self._full_key(key)
|
|
177
|
+
)
|
|
178
|
+
except Exception:
|
|
179
|
+
# botocore raises a dynamically-generated ClientError subclass
|
|
180
|
+
# (e.g. NoSuchKey) — catching Exception broadly here (rather
|
|
181
|
+
# than importing botocore just to catch its specific error
|
|
182
|
+
# type) keeps this backend usable with any S3-compatible client
|
|
183
|
+
# a caller supplies via the `client=` param.
|
|
184
|
+
return None
|
|
185
|
+
return response["Body"].read() # type: ignore[no-any-return]
|
|
186
|
+
|
|
187
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
188
|
+
full_prefix = self._full_key(prefix)
|
|
189
|
+
keys: list[str] = []
|
|
190
|
+
continuation_token: str | None = None
|
|
191
|
+
while True:
|
|
192
|
+
kwargs: dict[str, Any] = {"Bucket": self.bucket, "Prefix": full_prefix}
|
|
193
|
+
if continuation_token:
|
|
194
|
+
kwargs["ContinuationToken"] = continuation_token
|
|
195
|
+
response = self._client.list_objects_v2(**kwargs)
|
|
196
|
+
for obj in response.get("Contents", []):
|
|
197
|
+
remote_key = obj["Key"]
|
|
198
|
+
keys.append(
|
|
199
|
+
remote_key[len(self.prefix) + 1 :]
|
|
200
|
+
if self.prefix
|
|
201
|
+
else remote_key
|
|
202
|
+
)
|
|
203
|
+
if not response.get("IsTruncated"):
|
|
204
|
+
break
|
|
205
|
+
continuation_token = response.get("NextContinuationToken")
|
|
206
|
+
return sorted(keys)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class GCSRemoteFixtureBackend(RemoteFixtureBackend):
|
|
210
|
+
"""Google Cloud Storage-backed remote fixture store.
|
|
211
|
+
``google-cloud-storage`` is imported lazily."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, bucket: str, prefix: str = "", client: Any = None) -> None:
|
|
214
|
+
self.prefix = prefix.rstrip("/")
|
|
215
|
+
self._bucket = client or self._make_bucket(bucket)
|
|
216
|
+
|
|
217
|
+
def _make_bucket(self, bucket_name: str) -> Any:
|
|
218
|
+
try:
|
|
219
|
+
from google.cloud import storage
|
|
220
|
+
except ImportError as exc:
|
|
221
|
+
raise ImportError(_GCS_INSTALL_HINT) from exc
|
|
222
|
+
return storage.Client().bucket(bucket_name)
|
|
223
|
+
|
|
224
|
+
def _full_key(self, key: str) -> str:
|
|
225
|
+
return f"{self.prefix}/{key}" if self.prefix else key
|
|
226
|
+
|
|
227
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
228
|
+
blob = self._bucket.blob(self._full_key(key))
|
|
229
|
+
blob.upload_from_string(data)
|
|
230
|
+
|
|
231
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
232
|
+
blob = self._bucket.blob(self._full_key(key))
|
|
233
|
+
if not blob.exists():
|
|
234
|
+
return None
|
|
235
|
+
return blob.download_as_bytes() # type: ignore[no-any-return]
|
|
236
|
+
|
|
237
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
238
|
+
full_prefix = self._full_key(prefix)
|
|
239
|
+
blobs = self._bucket.list_blobs(prefix=full_prefix)
|
|
240
|
+
keys = [
|
|
241
|
+
blob.name[len(self.prefix) + 1 :] if self.prefix else blob.name
|
|
242
|
+
for blob in blobs
|
|
243
|
+
]
|
|
244
|
+
return sorted(keys)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Sync helpers
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def remote_sync_callback(
|
|
253
|
+
backend: RemoteFixtureBackend, run_id: str
|
|
254
|
+
) -> Any:
|
|
255
|
+
"""Return an ``on_exchange_recorded`` callback (see ``Fixture.__init__``)
|
|
256
|
+
that durably uploads each exchange to *backend* as it's recorded — so a
|
|
257
|
+
worker killed mid-run still has every exchange recorded up to that
|
|
258
|
+
point recoverable from remote storage, not just whatever made it into
|
|
259
|
+
the local, possibly-never-read-again ``fixture.db``.
|
|
260
|
+
|
|
261
|
+
Best-effort by design (matching Fixture's own on_exchange_recorded
|
|
262
|
+
contract): swallows every exception itself so a remote upload failure
|
|
263
|
+
can never break local recording.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def _callback(exchange: dict[str, Any]) -> None:
|
|
267
|
+
try:
|
|
268
|
+
key = f"{run_id}/exchanges/{exchange['sequence_num']:08d}.json"
|
|
269
|
+
backend.put_bytes(key, json.dumps(exchange).encode("utf-8"))
|
|
270
|
+
except Exception:
|
|
271
|
+
logger.warning(
|
|
272
|
+
"agent-trace: failed to sync exchange %s to remote backend",
|
|
273
|
+
exchange.get("sequence_num"),
|
|
274
|
+
exc_info=True,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return _callback
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def sync_run_to_remote(
|
|
281
|
+
run_dir: Path, backend: RemoteFixtureBackend, run_id: str
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Upload ``run_dir/fixture.db`` and ``run_dir/trace.json`` (whichever
|
|
284
|
+
exist) to *backend* under ``{run_id}/fixture.db`` /
|
|
285
|
+
``{run_id}/trace.json`` — a one-shot sync of an already-recorded run,
|
|
286
|
+
complementary to (not a replacement for) :func:`remote_sync_callback`'s
|
|
287
|
+
per-exchange durability during recording."""
|
|
288
|
+
for filename in ("fixture.db", "trace.json"):
|
|
289
|
+
path = run_dir / filename
|
|
290
|
+
if path.exists():
|
|
291
|
+
backend.put_bytes(f"{run_id}/{filename}", path.read_bytes())
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def restore_run_from_remote(
|
|
295
|
+
backend: RemoteFixtureBackend, run_id: str, dest_dir: Path
|
|
296
|
+
) -> Path:
|
|
297
|
+
"""Download ``{run_id}/fixture.db`` / ``{run_id}/trace.json`` (whichever
|
|
298
|
+
exist in *backend*) into ``dest_dir/run_id/``, returning that directory
|
|
299
|
+
— the recovery path for a worker whose local filesystem is gone but
|
|
300
|
+
whose recordings were synced to remote storage while it ran."""
|
|
301
|
+
run_dir = dest_dir / run_id
|
|
302
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
for filename in ("fixture.db", "trace.json"):
|
|
304
|
+
data = backend.get_bytes(f"{run_id}/{filename}")
|
|
305
|
+
if data is not None:
|
|
306
|
+
(run_dir / filename).write_bytes(data)
|
|
307
|
+
return run_dir
|