|
1 | 1 | from dataclasses import dataclass, field |
| 2 | +from datetime import datetime |
2 | 3 | from typing import Any, AsyncIterator, List, Optional |
3 | 4 |
|
| 5 | +from aiohttp import WSMsgType |
4 | 6 | from yarl import URL |
5 | 7 |
|
6 | 8 | from ._config import Config |
@@ -76,6 +78,13 @@ def _build_base_url( |
76 | 78 | ) |
77 | 79 | return url |
78 | 80 |
|
| 81 | + def _get_monitoring_url(self, cluster_name: Optional[str]) -> URL: |
| 82 | + if cluster_name is None: |
| 83 | + cluster_name = self._config.cluster_name |
| 84 | + return self._config.get_cluster(cluster_name).monitoring_url.with_path( |
| 85 | + "/api/v1" |
| 86 | + ) |
| 87 | + |
79 | 88 | @asyncgeneratorcontextmanager |
80 | 89 | async def list( |
81 | 90 | self, |
@@ -265,3 +274,58 @@ async def list_template_versions( |
265 | 274 | short_description=item.get("short_description", ""), |
266 | 275 | tags=item.get("tags", []), |
267 | 276 | ) |
| 277 | + |
| 278 | + @asyncgeneratorcontextmanager |
| 279 | + async def logs( |
| 280 | + self, |
| 281 | + app_id: str, |
| 282 | + *, |
| 283 | + cluster_name: Optional[str] = None, |
| 284 | + org_name: Optional[str] = None, |
| 285 | + project_name: Optional[str] = None, |
| 286 | + since: Optional[datetime] = None, |
| 287 | + timestamps: bool = False, |
| 288 | + ) -> AsyncIterator[bytes]: |
| 289 | + """Get logs for an app instance. |
| 290 | +
|
| 291 | + Args: |
| 292 | + app_id: The ID of the app instance |
| 293 | + cluster_name: Optional cluster name override |
| 294 | + org_name: Optional organization name override |
| 295 | + project_name: Optional project name override |
| 296 | + since: Optional timestamp to start logs from |
| 297 | + timestamps: Include timestamps in the logs output |
| 298 | +
|
| 299 | + Returns: |
| 300 | + An async iterator of log chunks as bytes |
| 301 | + """ |
| 302 | + url = self._get_monitoring_url(cluster_name) / "apps" / app_id / "log_ws" |
| 303 | + |
| 304 | + if url.scheme == "https": # pragma: no cover |
| 305 | + url = url.with_scheme("wss") |
| 306 | + else: |
| 307 | + url = url.with_scheme("ws") |
| 308 | + |
| 309 | + if since is not None: |
| 310 | + if since.tzinfo is None: |
| 311 | + # Interpret naive datetime object as local time. |
| 312 | + since = since.astimezone() # pragma: no cover |
| 313 | + url = url.update_query(since=since.isoformat()) |
| 314 | + if timestamps: |
| 315 | + url = url.update_query(timestamps="true") |
| 316 | + |
| 317 | + auth = await self._config._api_auth() |
| 318 | + async with self._core.ws_connect( |
| 319 | + url, |
| 320 | + auth=auth, |
| 321 | + timeout=None, |
| 322 | + heartbeat=30, |
| 323 | + ) as ws: |
| 324 | + async for msg in ws: |
| 325 | + if msg.type == WSMsgType.BINARY: |
| 326 | + if msg.data: |
| 327 | + yield msg.data |
| 328 | + elif msg.type == WSMsgType.ERROR: # pragma: no cover |
| 329 | + raise ws.exception() # type: ignore |
| 330 | + else: # pragma: no cover |
| 331 | + raise RuntimeError(f"Incorrect WebSocket message: {msg!r}") |
0 commit comments