plato-sdk-v2 2.1.11__py3-none-any.whl → 2.2.4__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.
@@ -0,0 +1,401 @@
1
+ """Chronos logging utilities.
2
+
3
+ Simple singleton-based logging for Chronos events and spans.
4
+
5
+ Usage:
6
+ from plato.agents.logging import init_logging, span, log_event, upload_logs
7
+
8
+ # Initialize once (typically in world setup)
9
+ init_logging(
10
+ callback_url="http://chronos/api/callback",
11
+ session_id="session-123",
12
+ )
13
+
14
+ # Use spans anywhere - parent tracking is automatic
15
+ async with span("clone_repo") as s:
16
+ s.log("Cloning...")
17
+
18
+ async with span("checkout"): # Automatically nested under clone_repo
19
+ s.log("Checking out...")
20
+
21
+ # Log events - parent is automatically the current span
22
+ await log_event(span_type="my_event", content="Something happened")
23
+
24
+ # Upload artifacts (zip of directory)
25
+ await upload_artifacts(dir_path="/path/to/logs")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import io
32
+ import logging
33
+ import zipfile
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any
37
+ from uuid import uuid4
38
+
39
+ import httpx
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class _ChronosLogger:
45
+ """Internal singleton logger for Chronos events."""
46
+
47
+ _instance: _ChronosLogger | None = None
48
+
49
+ def __init__(
50
+ self,
51
+ callback_url: str,
52
+ session_id: str,
53
+ parent_event_id: str | None = None,
54
+ ):
55
+ self.callback_url = callback_url.rstrip("/")
56
+ self.session_id = session_id
57
+ self.parent_event_id = parent_event_id
58
+ self._current_span_id: str | None = None
59
+ self._enabled = bool(callback_url and session_id)
60
+
61
+ @property
62
+ def enabled(self) -> bool:
63
+ return self._enabled
64
+
65
+ @property
66
+ def current_parent_id(self) -> str | None:
67
+ """Get the current parent ID (current span or root parent)."""
68
+ return self._current_span_id or self.parent_event_id
69
+
70
+ async def log_event(
71
+ self,
72
+ span_type: str,
73
+ content: str = "",
74
+ source: str = "agent",
75
+ log_type: str | None = None,
76
+ step_number: int | None = None,
77
+ extra: dict[str, Any] | None = None,
78
+ event_id: str | None = None,
79
+ parent_id: str | None = None,
80
+ started_at: str | None = None,
81
+ ended_at: str | None = None,
82
+ ) -> bool:
83
+ """Log an event to Chronos."""
84
+ if not self._enabled:
85
+ return False
86
+
87
+ try:
88
+ async with httpx.AsyncClient(timeout=30.0) as client:
89
+ response = await client.post(
90
+ f"{self.callback_url}/event",
91
+ json={
92
+ "session_id": self.session_id,
93
+ "span_type": span_type,
94
+ "content": content,
95
+ "source": source,
96
+ "log_type": log_type,
97
+ "step_number": step_number,
98
+ "extra": extra,
99
+ "event_id": event_id,
100
+ "parent_id": parent_id,
101
+ "started_at": started_at,
102
+ "ended_at": ended_at,
103
+ },
104
+ )
105
+ if response.status_code == 200:
106
+ return True
107
+ else:
108
+ logger.warning(f"Failed to log event: {response.status_code} {response.text}")
109
+ return False
110
+ except Exception as e:
111
+ logger.warning(f"Failed to log event to Chronos: {e}")
112
+ return False
113
+
114
+
115
+ class SpanContext:
116
+ """Context manager for logging spans with automatic timing and nesting."""
117
+
118
+ def __init__(
119
+ self,
120
+ name: str,
121
+ span_type: str = "span",
122
+ source: str = "agent",
123
+ extra: dict[str, Any] | None = None,
124
+ ):
125
+ self._name = name
126
+ self._span_type = span_type
127
+ self._source = source
128
+ self._extra = extra or {}
129
+ self._event_id: str | None = None
130
+ self._started_at: datetime | None = None
131
+ self._child_logs: list[str] = []
132
+ self._previous_span_id: str | None = None
133
+
134
+ async def __aenter__(self) -> SpanContext:
135
+ """Log span start and set as current span."""
136
+ self._started_at = datetime.now(timezone.utc)
137
+ self._event_id = str(uuid4())
138
+
139
+ chronos = _ChronosLogger._instance
140
+ if chronos and chronos.enabled:
141
+ # Save previous span and set self as current
142
+ self._previous_span_id = chronos._current_span_id
143
+ parent_id = chronos.current_parent_id
144
+
145
+ await chronos.log_event(
146
+ span_type=self._span_type,
147
+ content=f"{self._name} started",
148
+ source=self._source,
149
+ event_id=self._event_id,
150
+ parent_id=parent_id,
151
+ started_at=self._started_at.isoformat(),
152
+ extra=self._extra,
153
+ )
154
+
155
+ # Set self as the current span for child events
156
+ chronos._current_span_id = self._event_id
157
+
158
+ return self
159
+
160
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
161
+ """Log span end and restore previous span."""
162
+ ended_at = datetime.now(timezone.utc)
163
+ status = "failed" if exc_type else "completed"
164
+
165
+ chronos = _ChronosLogger._instance
166
+ if chronos and chronos.enabled:
167
+ # Restore previous span
168
+ chronos._current_span_id = self._previous_span_id
169
+ parent_id = self._previous_span_id or chronos.parent_event_id
170
+
171
+ # Update extra with any child logs
172
+ extra = {**self._extra}
173
+ if self._child_logs:
174
+ extra["logs"] = self._child_logs
175
+ if exc_type:
176
+ extra["error"] = str(exc_val)
177
+
178
+ await chronos.log_event(
179
+ span_type=self._span_type,
180
+ content=f"{self._name} {status}",
181
+ source=self._source,
182
+ event_id=self._event_id,
183
+ parent_id=parent_id,
184
+ started_at=self._started_at.isoformat() if self._started_at else None,
185
+ ended_at=ended_at.isoformat(),
186
+ extra=extra,
187
+ )
188
+
189
+ def log(self, message: str) -> None:
190
+ """Add a log message to the span."""
191
+ self._child_logs.append(message)
192
+ logger.info(f"[{self._name}] {message}")
193
+
194
+ def set_extra(self, data: dict[str, Any]) -> None:
195
+ """Update the span's extra data."""
196
+ self._extra.update(data)
197
+
198
+ @property
199
+ def event_id(self) -> str | None:
200
+ """Get the span's event ID (available after entering context)."""
201
+ return self._event_id
202
+
203
+
204
+ class _NoOpSpanContext:
205
+ """No-op span context when logging is not initialized."""
206
+
207
+ def __init__(self, name: str, **kwargs):
208
+ self._name = name
209
+ self._event_id = str(uuid4())
210
+
211
+ async def __aenter__(self) -> _NoOpSpanContext:
212
+ return self
213
+
214
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
215
+ pass
216
+
217
+ def log(self, message: str) -> None:
218
+ logger.debug(f"[{self._name}] {message}")
219
+
220
+ def set_extra(self, data: dict[str, Any]) -> None:
221
+ pass
222
+
223
+ @property
224
+ def event_id(self) -> str | None:
225
+ return self._event_id
226
+
227
+
228
+ # =============================================================================
229
+ # Public API
230
+ # =============================================================================
231
+
232
+
233
+ def init_logging(
234
+ callback_url: str,
235
+ session_id: str,
236
+ parent_event_id: str | None = None,
237
+ ) -> None:
238
+ """Initialize Chronos logging.
239
+
240
+ Call this once at startup (e.g., in world.run()).
241
+
242
+ Args:
243
+ callback_url: Chronos callback URL (e.g., http://chronos/api/callback)
244
+ session_id: Chronos session ID
245
+ parent_event_id: Optional root parent event ID (e.g., step ID)
246
+ """
247
+ _ChronosLogger._instance = _ChronosLogger(
248
+ callback_url=callback_url,
249
+ session_id=session_id,
250
+ parent_event_id=parent_event_id,
251
+ )
252
+ logger.info(f"Chronos logging initialized: session={session_id}")
253
+
254
+
255
+ def set_parent_event_id(parent_event_id: str | None) -> None:
256
+ """Set the root parent event ID.
257
+
258
+ Useful for updating the parent when entering a new step.
259
+ """
260
+ if _ChronosLogger._instance:
261
+ _ChronosLogger._instance.parent_event_id = parent_event_id
262
+
263
+
264
+ def span(
265
+ name: str,
266
+ span_type: str = "span",
267
+ source: str = "agent",
268
+ extra: dict[str, Any] | None = None,
269
+ ) -> SpanContext | _NoOpSpanContext:
270
+ """Create a span context manager.
271
+
272
+ Usage:
273
+ async with span("my_operation") as s:
274
+ s.log("Starting...")
275
+ # nested spans work automatically
276
+ async with span("sub_operation"):
277
+ ...
278
+
279
+ Args:
280
+ name: Span name/description
281
+ span_type: Event type for the span
282
+ source: Event source (agent, world, system)
283
+ extra: Additional data
284
+
285
+ Returns:
286
+ SpanContext (or NoOpSpanContext if logging not initialized)
287
+ """
288
+ if _ChronosLogger._instance and _ChronosLogger._instance.enabled:
289
+ return SpanContext(
290
+ name=name,
291
+ span_type=span_type,
292
+ source=source,
293
+ extra=extra,
294
+ )
295
+ else:
296
+ return _NoOpSpanContext(name=name)
297
+
298
+
299
+ async def log_event(
300
+ span_type: str,
301
+ content: str = "",
302
+ source: str = "agent",
303
+ log_type: str | None = None,
304
+ extra: dict[str, Any] | None = None,
305
+ parent_id: str | None = None,
306
+ ) -> bool:
307
+ """Log a single event to Chronos.
308
+
309
+ For spans with timing, use span() instead.
310
+ """
311
+ chronos = _ChronosLogger._instance
312
+ if not chronos or not chronos.enabled:
313
+ return False
314
+
315
+ return await chronos.log_event(
316
+ span_type=span_type,
317
+ content=content,
318
+ source=source,
319
+ log_type=log_type,
320
+ extra=extra,
321
+ parent_id=parent_id or chronos.current_parent_id,
322
+ )
323
+
324
+
325
+ def is_logging_enabled() -> bool:
326
+ """Check if Chronos logging is enabled."""
327
+ return _ChronosLogger._instance is not None and _ChronosLogger._instance.enabled
328
+
329
+
330
+ def reset_logging() -> None:
331
+ """Reset the logger (mainly for testing)."""
332
+ _ChronosLogger._instance = None
333
+
334
+
335
+ # =============================================================================
336
+ # Logs Upload
337
+ # =============================================================================
338
+
339
+
340
+ def zip_directory(dir_path: str) -> bytes:
341
+ """Zip an entire directory.
342
+
343
+ Args:
344
+ dir_path: Path to the directory
345
+
346
+ Returns:
347
+ Zip file contents as bytes.
348
+ """
349
+ path = Path(dir_path)
350
+ buffer = io.BytesIO()
351
+
352
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
353
+ for file_path in path.rglob("*"):
354
+ if file_path.is_file():
355
+ arcname = file_path.relative_to(path)
356
+ zf.write(file_path, arcname)
357
+
358
+ buffer.seek(0)
359
+ return buffer.read()
360
+
361
+
362
+ async def upload_artifacts(dir_path: str) -> str | None:
363
+ """Upload a directory as a zip to Chronos.
364
+
365
+ Args:
366
+ dir_path: Path to the directory to upload
367
+
368
+ Returns:
369
+ URL if successful, None otherwise.
370
+ """
371
+ chronos = _ChronosLogger._instance
372
+ if not chronos or not chronos.enabled:
373
+ return None
374
+
375
+ try:
376
+ zip_data = zip_directory(dir_path)
377
+ zip_base64 = base64.b64encode(zip_data).decode("utf-8")
378
+ logger.info(f"Zipped directory: {len(zip_data)} bytes")
379
+ except Exception as e:
380
+ logger.warning(f"Failed to zip directory: {e}")
381
+ return None
382
+
383
+ try:
384
+ async with httpx.AsyncClient(timeout=60.0) as client:
385
+ response = await client.post(
386
+ f"{chronos.callback_url}/logs-upload",
387
+ json={
388
+ "session_id": chronos.session_id,
389
+ "logs_base64": zip_base64,
390
+ },
391
+ )
392
+ if response.status_code == 200:
393
+ result = response.json()
394
+ logger.info(f"Uploaded artifacts: {result}")
395
+ return result.get("logs_url")
396
+ else:
397
+ logger.warning(f"Failed to upload artifacts: {response.status_code} {response.text}")
398
+ return None
399
+ except Exception as e:
400
+ logger.warning(f"Failed to upload artifacts: {e}")
401
+ return None