Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ac5b5be
LFRequest.py : Capture response code and diagnostics summary for each…
Sidartha-CT Jul 22, 2026
70a8160
api_logger.py : Add process-wide API call CSV logger
Sidartha-CT Jul 22, 2026
91b1419
lfcli_base.py : Record json_get/post/put/delete calls via api_logger
Sidartha-CT Jul 22, 2026
94919aa
lanforge_api.py : Record json_get/post/put/delete calls via api_logger
Sidartha-CT Jul 22, 2026
dfb2931
lf_webpage.py : Add --save_api and --api_log_file_name to enable API …
Sidartha-CT Jul 22, 2026
ae79169
api_logger.py : Add pause/resume to temporarily suppress recording
Sidartha-CT Jul 22, 2026
36fdbec
lf_webpage.py : Pause API logging during runtime-CSV monitor loop by …
Sidartha-CT Jul 22, 2026
a2ad137
api_logger.py : Accept caller-supplied sent_at for accurate call time…
Sidartha-CT Aug 3, 2026
7017195
lanforge_api.py : Capture request timestamp before urlopen, not after
Sidartha-CT Aug 3, 2026
a789c77
LFRequest.py : Track last_sent_at right before request is issued
Sidartha-CT Aug 3, 2026
08e6674
lfcli_base.py : Forward LFRequest.last_sent_at through to the API logger
Sidartha-CT Aug 3, 2026
c57846c
api_logger.py : Add api_elapsed_ms column for per-call duration
Sidartha-CT Aug 3, 2026
883dadd
lanforge_api.py : Measure request duration with time.perf_counter()
Sidartha-CT Aug 3, 2026
30f334d
api_logger.py : Use "-" placeholder for empty payload
Sidartha-CT Aug 3, 2026
99bf697
lf_webpage.py : Rename --save_api to --enable_api_logging
Sidartha-CT Aug 3, 2026
bdfd95b
LFRequest.py : Track last_elapsed_ms around urlopen() for API call du…
Sidartha-CT Aug 3, 2026
11a1c8d
lfcli_base.py : Forward LFRequest.last_elapsed_ms through to the API …
Sidartha-CT Aug 3, 2026
ddd8b8e
api_logger.py : Use pathlib.Path.home() instead of os.path.expanduser
Sidartha-CT Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions lanforge_client/api_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""
Process-wide, opt-in CSV logging of LANforge API calls (GET/POST/PUT/DELETE), for
debugging a particular test run.

Call configure_api_call_logging() once, near the start of a script; every API call
made afterward in the same process is then recorded automatically.
"""
import csv
import datetime
import json
import logging
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

# CSV header for API call logs with prefixed column names to avoid ambiguity across reports.
CSV_FIELDS = ['api_timestamp', 'api_method', 'api_url', 'api_status', 'api_response_code',
'api_elapsed_ms', 'api_payload', 'api_error', 'api_diagnostics']

_logging_enabled = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would appreciate an alternative to using globals. Can we turn these into class members?

_logging_paused = False
_log_filename = None


def configure_api_call_logging(enabled: bool, log_filename: Optional[str] = None) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason we cannot design these methods into an ApiLogger class?

"""
Enable or disable process-wide API-call logging.

Args:
enabled: True to turn logging on, False to turn it off.
log_filename: Path to the CSV log file; defaults to ~/lf_api_calls.csv when not set.

Returns:
None.
"""
global _logging_enabled, _log_filename, _logging_paused

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would appreciate an alternative to using globals.

_logging_enabled = bool(enabled)
_logging_paused = False
if not _logging_enabled:
return
_log_filename = log_filename or str(Path.home() / 'lf_api_calls.csv')
# start each run with a clean log file
try:
with open(_log_filename, 'w', newline='') as csv_file:
csv.writer(csv_file).writerow(CSV_FIELDS)
except Exception as x:
logger.debug("api_logger: unable to reset %s: %s" % (_log_filename, x))


def pause() -> None:
"""
Temporarily stop recording API calls until resume() is called.

Returns:
None.
"""
global _logging_paused
_logging_paused = True


def resume() -> None:
"""
Resume recording API calls after pause().

Returns:
None.
"""
global _logging_paused
_logging_paused = False


def is_enabled() -> bool:
"""
Check whether API-call logging is currently enabled.

Returns:
True if logging is enabled, False otherwise.
"""
return _logging_enabled


def get_log_filename() -> Optional[str]:
"""
Get the path of the API-call log CSV file.

Returns:
The log file path, or None if logging has not been configured.
"""
return _log_filename


def record_api_call(method: str, url: str, data: Optional[Any] = None, response_code: Optional[int] = None,
error: Optional[Exception] = None, diagnostics: Optional[str] = None,
sent_at: Optional[datetime.datetime] = None,
elapsed_ms: Optional[float] = None) -> None:
"""
Append one CSV row for a json_get/json_post/json_put/json_delete call. No-op unless
configure_api_call_logging(enabled=True) was called first, or while paused (see pause()/resume()).

Args:
method: "GET" | "POST" | "PUT" | "DELETE".
url: Requested url.
data: Payload sent (POST/PUT only).
response_code: HTTP status code returned by the call, if any.
error: Exception raised by the call, if any -- marks the entry as ERROR.
diagnostics: One-line summary from LFRequest.print_diagnostics(), if the call
went through a caught HTTPError/URLError (reason, X-Error-* headers, etc.)
sent_at: Timestamp captured by the caller right before the request was issued.
Falls back to datetime.now() (this function's call time) when not provided,
which is captured well after the request completed and is less accurate.
elapsed_ms: Wall-clock duration of the underlying urlopen() call in milliseconds,
measured by the caller with time.perf_counter() around just the request itself
(excludes diagnostics/logging overhead). None when not measured.

Returns:
None.
"""
if not _logging_enabled or _logging_paused:
return
if error:
status = "ERROR"
elif response_code:
status = "OK" if 200 <= response_code < 300 else "ERROR"
else:
status = "UNKNOWN"
try:
with open(_log_filename, 'a', newline='') as csv_file:
csv.writer(csv_file).writerow([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the time the message is sent, it refers to the time that the csv is recording this data

(sent_at or datetime.datetime.now()).isoformat(),
method,
url,
status,
response_code if response_code is not None else 'No response_code',
round(elapsed_ms, 3) if elapsed_ms is not None else 'Unknown',
json.dumps(data, default=str) if data is not None else '-',
error,
diagnostics if diagnostics is not None else 'No diagnostics',
])
except Exception as x:
logger.debug("api_logger: unable to write %s: %s" % (_log_filename, x))
57 changes: 48 additions & 9 deletions lanforge_client/lanforge_api.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like all of these changes will be lost on the next API generation. These changes need to live in btbits/client/candela/lanforge/json_api.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out. Is this something that needs to be updated on the btbits side first? I don't have access to that repository, so I'm not very familiar with how the API generation flow works. If these changes need to be made there before they're generated into this repo, please let me know.

Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class which appends subclasses to it.

# - - - - deployed import references - - - - -
from .strutil import nott, iss
from . import api_logger

SESSION_HEADER = 'X-LFJson-Session'
# LOGGER = Logger('json_api')
Expand Down Expand Up @@ -173,6 +174,10 @@ def print_diagnostics(url_: str = None,
error_list_.append(xerr)
LOGGER.error(" = = = = = = = = = = = = = = = =")

summary = "%s <%s> HTTP %s: %s" % (method, err_full_url, err_code, err_reason)
if xerrors and err_code != 404:
summary += " | " + "; ".join(xerrors)

if error_.__class__ is urllib.error.HTTPError:
LOGGER.debug("----- HTTPError: ------------------------------------ print_diagnostics:")
LOGGER.debug("%s <%s> HTTP %s: %s" % (method, err_full_url, err_code, err_reason))
Expand Down Expand Up @@ -203,7 +208,7 @@ def print_diagnostics(url_: str = None,
LOGGER.warning("------------------------------------------------------------------------")
if die_on_error_:
exit(1)
return
return summary

if error_.__class__ is urllib.error.URLError:
LOGGER.error("----- URLError: ---------------------------------------------")
Expand All @@ -212,6 +217,8 @@ def print_diagnostics(url_: str = None,
if die_on_error_:
exit(1)

return summary


class BaseLFJsonRequest:
"""----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- -----
Expand Down Expand Up @@ -573,9 +580,12 @@ def json_post(self,
finish_time_ms = (max_timeout_sec * 1000) + begin_time_ms
attempt = 1
while (time.time() * 1000) < finish_time_ms:
sent_at = datetime.now()
_t0 = time.perf_counter()
try:
response = urllib.request.urlopen(myrequest)
resp_data = response.read().decode('utf-8')
elapsed_ms = (time.perf_counter() - _t0) * 1000
if self.receives_async_feedback and (response_json_list is None and resp_data):
self.logger.warning("json_post: POST to URL has data: " + url)
raise ValueError("json_post: not returning post data, no response_json_list provided")
Expand Down Expand Up @@ -635,37 +645,52 @@ def json_post(self,
self.logger.debug("----------------- BAD STATUS --------------------------------")
if die_on_error:
sys.exit(1)
api_logger.record_api_call(method=method_, url=url, data=post_data, response_code=response.status,
sent_at=sent_at, elapsed_ms=elapsed_ms)
return responses[0]

except urllib.error.HTTPError as herror:
elapsed_ms = (time.perf_counter() - _t0) * 1000
# these error codes illustrate an error on the client that requires debugging
# and retrying them is never going to succeed
if herror.code in (400, 410, 411, 412, 413, 414, 415, 416, 417, 428, 429, 431, 451):
die_on_error = True
print_diagnostics(url_=url,
# die_on_error_=False here: we want to log the call before exiting, so the
# sys.exit(1) below (not print_diagnostics' own) is what actually exits
diagnostics = print_diagnostics(url_=url,
request_=myrequest,
responses_=responses,
error_=herror,
debug_=debug,
die_on_error_=die_on_error)
die_on_error_=False)
api_logger.record_api_call(method=method_, url=url, data=post_data, response_code=herror.code,
error=herror, diagnostics=diagnostics, sent_at=sent_at,
elapsed_ms=elapsed_ms)
if die_on_error:
sys.exit(1)

except urllib.error.URLError as uerror:
elapsed_ms = (time.perf_counter() - _t0) * 1000
# this is a misformatted URL
die_on_error = True
if (url.endswith("endsession")):
logging.info("lfclient closed connection before script exit")
api_logger.record_api_call(method=method_, url=url, data=post_data, error=uerror,
diagnostics="session ended", sent_at=sent_at,
elapsed_ms=elapsed_ms)
die_on_error = True
break
else:
logging.error("Connection refused: "+url)
print_diagnostics(url_=url,
diagnostics = print_diagnostics(url_=url,
request_=myrequest,
responses_=responses,
error_=uerror,
debug_=debug,
die_on_error_=die_on_error)
die_on_error_=False)
api_logger.record_api_call(method=method_, url=url, data=post_data, error=uerror,
diagnostics=diagnostics, sent_at=sent_at,
elapsed_ms=elapsed_ms)
if die_on_error:
sys.exit(1)
# ~while
Expand Down Expand Up @@ -798,28 +823,42 @@ def get(self,
myrequest.timeout = connection_timeout_sec

myresponses: list = [] # list[HTTPResponse]
sent_at = datetime.now()
_t0 = time.perf_counter()
try:
myresponses.append(request.urlopen(myrequest))
elapsed_ms = (time.perf_counter() - _t0) * 1000
api_logger.record_api_call(method=method_, url=requested_url, response_code=myresponses[0].status,
sent_at=sent_at, elapsed_ms=elapsed_ms)
return myresponses[0]

except urllib.error.HTTPError as herror:
print_diagnostics(url_=requested_url,
elapsed_ms = (time.perf_counter() - _t0) * 1000
# die_on_error_=False here: we want to log the call before exiting, so the
# sys.exit(1) below (not print_diagnostics' own) is what actually exits
diagnostics = print_diagnostics(url_=requested_url,
request_=myrequest,
responses_=myresponses,
error_=herror,
error_list_=self.error_list,
debug_=debug,
die_on_error_=die_on_error)
die_on_error_=False)
api_logger.record_api_call(method=method_, url=requested_url, response_code=herror.code,
error=herror, diagnostics=diagnostics, sent_at=sent_at,
elapsed_ms=elapsed_ms)
if die_on_error:
sys.exit(1)
except urllib.error.URLError as uerror:
print_diagnostics(url_=requested_url,
elapsed_ms = (time.perf_counter() - _t0) * 1000
diagnostics = print_diagnostics(url_=requested_url,
request_=myrequest,
responses_=myresponses,
error_=uerror,
error_list_=self.error_list,
debug_=debug,
die_on_error_=die_on_error)
die_on_error_=False)
api_logger.record_api_call(method=method_, url=requested_url, error=uerror, diagnostics=diagnostics,
sent_at=sent_at, elapsed_ms=elapsed_ms)
if die_on_error:
sys.exit(1)
if die_on_error:
Expand Down
Loading
Loading