Skip to content

Commit 0f83678

Browse files
authored
S3 logs (#928)
* capture logger to file * dont interfere with other loggers * send logs * code cleanup * moved logging instrumentation * some testing * some testing
1 parent 92d1ec3 commit 0f83678

File tree

6 files changed

+201
-7
lines changed

6 files changed

+201
-7
lines changed

agentops/client/api/versions/v4.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,35 @@ def upload_object(self, body: Union[str, bytes]) -> UploadedObjectResponse:
6868
return UploadedObjectResponse(**response_data)
6969
except Exception as e:
7070
raise ApiServerException(f"Failed to process upload response: {str(e)}")
71+
72+
73+
def upload_logfile(self, body: Union[str, bytes], trace_id: int) -> UploadedObjectResponse:
74+
"""
75+
Upload an log file to the API and return the response.
76+
77+
Args:
78+
body: The log file to upload, either as a string or bytes.
79+
Returns:
80+
UploadedObjectResponse: The response from the API after upload.
81+
"""
82+
if isinstance(body, bytes):
83+
body = body.decode("utf-8")
84+
85+
response = self.post("/v4/logs/upload/", body, {**self.prepare_headers(), "Trace-Id": str(trace_id)})
86+
87+
if response.status_code != 200:
88+
error_msg = f"Upload failed: {response.status_code}"
89+
try:
90+
error_data = response.json()
91+
if "error" in error_data:
92+
error_msg = error_data["error"]
93+
except Exception:
94+
pass
95+
raise ApiServerException(error_msg)
96+
97+
try:
98+
response_data = response.json()
99+
return UploadedObjectResponse(**response_data)
100+
except Exception as e:
101+
raise ApiServerException(f"Failed to process upload response: {str(e)}")
71102

agentops/logging/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .config import configure_logging, logger
2+
from .instrument_logging import setup_print_logger, upload_logfile
23

3-
__all__ = ["logger", "configure_logging"]
4+
__all__ = ["logger", "configure_logging", "setup_print_logger", "upload_logfile"]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import builtins
2+
import logging
3+
import os
4+
import atexit
5+
from datetime import datetime
6+
from typing import Any, TextIO
7+
from agents import Span
8+
9+
_original_print = builtins.print
10+
11+
LOGFILE_NAME = "agentops-tmp.log"
12+
13+
## Instrument loggers and print function to log to a file
14+
def setup_print_logger() -> None:
15+
"""
16+
~Monkeypatches~ *Instruments the built-in print function and configures logging to also log to a file.
17+
Preserves existing logging configuration and console output behavior.
18+
"""
19+
log_file = os.path.join(os.getcwd(), LOGFILE_NAME)
20+
21+
file_logger = logging.getLogger('agentops_file_logger')
22+
file_logger.setLevel(logging.DEBUG)
23+
24+
file_handler = logging.FileHandler(log_file)
25+
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
26+
file_handler.setLevel(logging.DEBUG)
27+
file_logger.addHandler(file_handler)
28+
29+
# Ensure the new logger doesn't propagate to root
30+
file_logger.propagate = False
31+
32+
def print_logger(*args: Any, **kwargs: Any) -> None:
33+
"""
34+
Custom print function that logs to file and console.
35+
36+
Args:
37+
*args: Arguments to print
38+
**kwargs: Keyword arguments to print
39+
"""
40+
message = " ".join(str(arg) for arg in args)
41+
file_logger.info(message)
42+
43+
# print to console using original print
44+
_original_print(*args, **kwargs)
45+
46+
# replace the built-in print with ours
47+
builtins.print = print_logger
48+
49+
def cleanup():
50+
"""
51+
Cleanup function to be called when the process exits.
52+
Removes the log file and restores the original print function.
53+
"""
54+
try:
55+
# Remove our file handler
56+
for handler in file_logger.handlers[:]:
57+
handler.close()
58+
file_logger.removeHandler(handler)
59+
60+
# Restore the original print function
61+
builtins.print = _original_print
62+
except Exception as e:
63+
# If something goes wrong during cleanup, just print the error
64+
_original_print(f"Error during cleanup: {e}")
65+
66+
# Register the cleanup function to run when the process exits
67+
atexit.register(cleanup)
68+
69+
70+
def upload_logfile(trace_id: int) -> None:
71+
"""
72+
Upload the log file to the API.
73+
"""
74+
from agentops import get_client
75+
76+
log_file = os.path.join(os.getcwd(), LOGFILE_NAME)
77+
if not os.path.exists(log_file):
78+
return
79+
with open(log_file, "r") as f:
80+
log_content = f.read()
81+
82+
client = get_client()
83+
client.api.v4.upload_logfile(log_content, trace_id)
84+
85+
os.remove(log_file)
86+

agentops/sdk/core.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from opentelemetry import context as context_api
2222

2323
from agentops.exceptions import AgentOpsClientNotInitializedException
24-
from agentops.logging import logger
24+
from agentops.logging import logger, setup_print_logger
2525
from agentops.sdk.processors import InternalSpanProcessor
2626
from agentops.sdk.types import TracingConfig
2727
from agentops.semconv import ResourceAttributes
@@ -170,6 +170,9 @@ def setup_telemetry(
170170
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
171171
metrics.set_meter_provider(meter_provider)
172172

173+
### Logging
174+
setup_print_logger()
175+
173176
# Initialize root context
174177
context_api.get_current()
175178

@@ -256,8 +259,8 @@ def initialize(self, jwt: Optional[str] = None, **kwargs) -> None:
256259
self._provider, self._meter_provider = setup_telemetry(
257260
service_name=config["service_name"] or "",
258261
project_id=config.get("project_id"),
259-
exporter_endpoint=config["exporter_endpoint"] or "",
260-
metrics_endpoint=config["metrics_endpoint"] or "",
262+
exporter_endpoint=config["exporter_endpoint"],
263+
metrics_endpoint=config["metrics_endpoint"],
261264
max_queue_size=config["max_queue_size"],
262265
max_wait_time=config["max_wait_time"],
263266
export_flush_interval=config["export_flush_interval"],

agentops/sdk/processors.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
1313
from opentelemetry.sdk.trace.export import SpanExporter
1414

15-
import agentops.semconv as semconv
1615
from agentops.logging import logger
1716
from agentops.helpers.dashboard import log_trace_url
1817
from agentops.semconv.core import CoreAttributes
19-
18+
from agentops.logging import upload_logfile
2019

2120
class LiveSpanProcessor(SpanProcessor):
2221
def __init__(self, span_exporter: SpanExporter, **kwargs):
@@ -91,7 +90,7 @@ class InternalSpanProcessor(SpanProcessor):
9190
- This processor tries to use the native kind first, then falls back to the attribute
9291
"""
9392

94-
_root_span_id: Optional[Span] = None
93+
_root_span_id: Optional[int] = None
9594

9695
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
9796
"""
@@ -124,6 +123,10 @@ def on_end(self, span: ReadableSpan) -> None:
124123
if self._root_span_id and (span.context.span_id is self._root_span_id):
125124
logger.debug(f"[agentops.InternalSpanProcessor] Ending root span: {span.name}")
126125
log_trace_url(span)
126+
try:
127+
upload_logfile(span.context.trace_id)
128+
except Exception as e:
129+
logger.error(f"[agentops.InternalSpanProcessor] Error uploading logfile: {e}")
127130

128131
def shutdown(self) -> None:
129132
"""Shutdown the processor."""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import os
2+
import builtins
3+
import pytest
4+
from unittest.mock import patch, MagicMock
5+
from agentops.logging.instrument_logging import setup_print_logger, upload_logfile, LOGFILE_NAME
6+
import logging
7+
8+
@pytest.fixture
9+
def cleanup_log_file():
10+
"""Fixture to clean up the log file before and after tests"""
11+
log_file = os.path.join(os.getcwd(), LOGFILE_NAME)
12+
if os.path.exists(log_file):
13+
os.remove(log_file)
14+
yield
15+
if os.path.exists(log_file):
16+
os.remove(log_file)
17+
18+
def test_setup_print_logger_creates_log_file(cleanup_log_file):
19+
"""Test that setup_print_logger creates a log file"""
20+
setup_print_logger()
21+
log_file = os.path.join(os.getcwd(), LOGFILE_NAME)
22+
assert os.path.exists(log_file)
23+
24+
def test_print_logger_writes_to_file(cleanup_log_file):
25+
"""Test that the monkeypatched print function writes to the log file"""
26+
setup_print_logger()
27+
test_message = "Test log message"
28+
print(test_message)
29+
30+
log_file = os.path.join(os.getcwd(), LOGFILE_NAME)
31+
with open(log_file, 'r') as f:
32+
log_content = f.read()
33+
assert test_message in log_content
34+
35+
def test_print_logger_preserves_original_print(cleanup_log_file):
36+
"""Test that the original print function is preserved"""
37+
original_print = builtins.print
38+
setup_print_logger()
39+
assert builtins.print != original_print
40+
41+
# Cleanup should restore original print
42+
for handler in logging.getLogger('agentops_file_logger').handlers[:]:
43+
handler.close()
44+
logging.getLogger('agentops_file_logger').removeHandler(handler)
45+
builtins.print = original_print
46+
47+
@patch('agentops.get_client')
48+
def test_upload_logfile(mock_get_client, cleanup_log_file):
49+
"""Test that upload_logfile reads and uploads log content"""
50+
# Setup
51+
setup_print_logger()
52+
test_message = "Test upload message"
53+
print(test_message)
54+
55+
# Mock the client
56+
mock_client = MagicMock()
57+
mock_get_client.return_value = mock_client
58+
59+
# Test upload
60+
upload_logfile(trace_id=123)
61+
62+
# Verify
63+
mock_client.api.v4.upload_logfile.assert_called_once()
64+
assert not os.path.exists(os.path.join(os.getcwd(), LOGFILE_NAME))
65+
66+
def test_upload_logfile_nonexistent_file():
67+
"""Test that upload_logfile handles nonexistent log file gracefully"""
68+
with patch('agentops.get_client') as mock_get_client:
69+
upload_logfile(trace_id=123)
70+
mock_get_client.assert_not_called()

0 commit comments

Comments
 (0)