mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
fix(langsmith): json.dumps with default=str so non-serializable metadata does not crash batch flush (#42424)
* fix(langsmith): json.dumps with default=str so non-serializable metadata does not crash batch flush Serialize the runs/batch payload with json.dumps(default=str, allow_nan=False) and send it as content= with an explicit Content-Type, so datetime, Decimal and similar metadata values no longer raise TypeError and drop the batch. Forward content= on the AsyncHTTPHandler retry path so a retried batch re-sends the identical body Replaces #39133, which was cut from the retired staging branch and conflicts with main Co-authored-by: Damien Smrt <dsmrt@users.noreply.github.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): drop test docstrings and replace monkeypatch with a client-injecting handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): add live e2e for non-native metadata reaching LangSmith Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(langsmith): scope the e2e docstring to the values the test injects Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(http_handler): close injected retry clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): deselect the LangSmith live e2e on the stage-mirror stack Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: Damien Smrt <dsmrt@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
05d7fb24bd
commit
2ef710e3d5
8 changed files with 304 additions and 10 deletions
1
.github/e2e-stack/select_tests.py
vendored
1
.github/e2e-stack/select_tests.py
vendored
|
|
@ -10,6 +10,7 @@ UNSUPPORTED: Final = re.compile(
|
|||
r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$"
|
||||
r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$"
|
||||
r"|^tests/e2e/logging/test_otel_v2_langfuse_generation_output_e2e\.py$"
|
||||
r"|^tests/e2e/logging/test_langsmith_batch_serialization_e2e\.py$"
|
||||
)
|
||||
HARNESS: Final = re.compile(
|
||||
r"^tests/e2e/[A-Za-z0-9_.-]+\.(py|ini)$"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#### What this does ####
|
||||
# On success, logs events to Langsmith
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import traceback
|
||||
|
|
@ -415,7 +416,7 @@ class LangsmithLogger(CustomBatchLogger):
|
|||
langsmith_api_key: Final = credentials["LANGSMITH_API_KEY"]
|
||||
langsmith_tenant_id: Final = credentials.get("LANGSMITH_TENANT_ID")
|
||||
url: Final = self._add_endpoint_to_url(langsmith_api_base, "runs/batch")
|
||||
headers: Final = {"x-api-key": langsmith_api_key}
|
||||
headers: Final = {"x-api-key": langsmith_api_key, "Content-Type": "application/json"}
|
||||
if langsmith_tenant_id:
|
||||
headers["x-tenant-id"] = langsmith_tenant_id
|
||||
elements_to_log: Final = [queue_object["data"] for queue_object in queue_objects]
|
||||
|
|
@ -426,7 +427,7 @@ class LangsmithLogger(CustomBatchLogger):
|
|||
verbose_logger.debug("[LANGSMITH MOCK] Mock mode enabled - API calls will be intercepted")
|
||||
response: Final = await self.async_httpx_client.post(
|
||||
url=url,
|
||||
json={"post": elements_to_log},
|
||||
content=json.dumps({"post": elements_to_log}, default=str, allow_nan=False),
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -845,6 +845,7 @@ class AsyncHTTPHandler:
|
|||
params=params,
|
||||
headers=headers,
|
||||
stream=stream,
|
||||
content=content,
|
||||
)
|
||||
finally:
|
||||
await new_client.aclose()
|
||||
|
|
@ -985,6 +986,7 @@ class AsyncHTTPHandler:
|
|||
params=params,
|
||||
headers=headers,
|
||||
stream=stream,
|
||||
content=content,
|
||||
)
|
||||
finally:
|
||||
await new_client.aclose()
|
||||
|
|
@ -1051,6 +1053,7 @@ class AsyncHTTPHandler:
|
|||
params=params,
|
||||
headers=headers,
|
||||
stream=stream,
|
||||
content=content,
|
||||
)
|
||||
finally:
|
||||
await new_client.aclose()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
- {id: logging.otel.failure.exports_metric, module: logging, tier: P0, event: failure, assertions: [exports_metric], exercised_on: [chat_completions, messages], source: "integrations/otel/logger.py", rationale: "Error spans for observability continuity"}
|
||||
- {id: logging.braintrust.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/braintrust_logging.py", rationale: "Evals platform spend"}
|
||||
- {id: logging.langsmith.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/langsmith.py", rationale: "LangChain ecosystem"}
|
||||
- {id: logging.langsmith.success.serializes_non_native_metadata, module: logging, tier: P1, event: success, assertions: [serializes_non_native_metadata], exercised_on: [sdk], source: "integrations/langsmith.py", rationale: "datetime/Decimal/UUID metadata used to TypeError in json.dumps and drop the whole batch (LIT-8310)"}
|
||||
- {id: logging.arize.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, embeddings], source: "integrations/arize/arize.py", rationale: "ML-ops observability"}
|
||||
- {id: logging.mlflow.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/mlflow.py", rationale: "Experiment tracking cost/run"}
|
||||
- {id: logging.opik.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/opik/opik.py", rationale: "Eval platform spend/case"}
|
||||
|
|
|
|||
125
tests/e2e/logging/test_langsmith_batch_serialization_e2e.py
Normal file
125
tests/e2e/logging/test_langsmith_batch_serialization_e2e.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Live e2e: a LangSmith batch whose metadata holds non JSON-native Python values
|
||||
(datetime, Decimal) must reach the real LangSmith API instead of dying in
|
||||
json.dumps and dropping the whole batch. Only the SDK path can put such values
|
||||
into the batch (the proxy JSON-decodes request metadata), so this test drives
|
||||
litellm.acompletion in-process against the real OpenAI API with a LangsmithLogger
|
||||
injected per request, flushes the batch, and reads the run back by id through
|
||||
LangSmith's own API. Nothing is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import decimal
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from e2e_config import CHEAP_OPENAI_MODEL, POLL_INTERVAL, POLL_TIMEOUT, unique_marker
|
||||
from e2e_http import Headers, Success, get_external
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.langsmith import LangsmithLogger
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
class LangsmithHeaders(Headers):
|
||||
x_api_key: str = Field(serialization_alias="x-api-key")
|
||||
|
||||
|
||||
class LangsmithRunExtra(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
requester_metadata: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
class LangsmithRun(BaseModel):
|
||||
id: str
|
||||
session_name: str | None = None
|
||||
extra: LangsmithRunExtra
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LangsmithCreds:
|
||||
api_key: str
|
||||
base_url: str
|
||||
project: str
|
||||
|
||||
|
||||
def load_langsmith_creds() -> LangsmithCreds:
|
||||
api_key = os.getenv("LANGSMITH_API_KEY")
|
||||
if not api_key:
|
||||
pytest.fail("LangSmith e2e requires LANGSMITH_API_KEY; missing credentials is a hard failure, not a skip")
|
||||
if os.getenv("LANGSMITH_MOCK"):
|
||||
pytest.fail("LANGSMITH_MOCK is set; this e2e must hit the real LangSmith API")
|
||||
return LangsmithCreds(
|
||||
api_key=api_key,
|
||||
base_url=(os.getenv("LANGSMITH_BASE_URL") or "https://api.smith.langchain.com").rstrip("/"),
|
||||
project=os.getenv("LANGSMITH_PROJECT") or "litellm-e2e",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun | None:
|
||||
result = get_external(
|
||||
f"{creds.base_url}/runs/{run_id}",
|
||||
response_type=LangsmithRun,
|
||||
headers=LangsmithHeaders(x_api_key=creds.api_key),
|
||||
)
|
||||
match result:
|
||||
case Success(data=run):
|
||||
return run
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _poll_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun:
|
||||
deadline: Final = time.monotonic() + POLL_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
run = _fetch_run(creds, run_id)
|
||||
if run is not None:
|
||||
return run
|
||||
time.sleep(POLL_INTERVAL)
|
||||
pytest.fail(f"LangSmith run {run_id} never appeared within {POLL_TIMEOUT}s; the batch flush dropped it")
|
||||
|
||||
|
||||
class TestLangsmithBatchSerialization:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.covers("logging.langsmith.success.serializes_non_native_metadata")
|
||||
async def test_non_json_native_metadata_reaches_langsmith(self) -> None:
|
||||
creds: Final = load_langsmith_creds()
|
||||
logger: Final = LangsmithLogger(
|
||||
langsmith_api_key=creds.api_key, langsmith_project=creds.project, langsmith_base_url=creds.base_url
|
||||
)
|
||||
assert not logger.is_mock_mode, "LangsmithLogger initialised in mock mode; this e2e needs the real API"
|
||||
marker: Final = unique_marker()
|
||||
run_id: Final = uuid.uuid4()
|
||||
created_at: Final = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
|
||||
spend: Final = decimal.Decimal("0.0042")
|
||||
response: Final = await litellm.acompletion(
|
||||
model=f"openai/{CHEAP_OPENAI_MODEL}",
|
||||
messages=[{"role": "user", "content": f"Reply with the single word ok ({marker})"}],
|
||||
max_completion_tokens=5,
|
||||
callbacks=[logger],
|
||||
metadata={"run_id": str(run_id), "metadata": {"marker": marker, "created_at": created_at, "spend": spend}},
|
||||
)
|
||||
assert isinstance(response, litellm.ModelResponse) and response.id, (
|
||||
"a non-streaming completion must return a ModelResponse before the batch flush is meaningful"
|
||||
)
|
||||
enqueue_deadline: Final = time.monotonic() + POLL_TIMEOUT
|
||||
while time.monotonic() < enqueue_deadline and len(logger.log_queue) == 0:
|
||||
await asyncio.sleep(0.5)
|
||||
assert len(logger.log_queue) == 1, (
|
||||
f"the completion must be queued for the LangSmith batch, got {len(logger.log_queue)} queued entries"
|
||||
)
|
||||
await logger.async_send_batch()
|
||||
run: Final = _poll_run(creds, run_id)
|
||||
requester_metadata: Final = run.extra.requester_metadata
|
||||
assert requester_metadata is not None, "the run must carry the caller metadata under extra.requester_metadata"
|
||||
assert requester_metadata["marker"] == marker
|
||||
assert requester_metadata["created_at"] == str(created_at)
|
||||
assert requester_metadata["spend"] == str(spend)
|
||||
|
|
@ -356,10 +356,12 @@ async def test_langsmith_key_based_logging():
|
|||
# tenant_id should not be in headers if not provided
|
||||
assert "x-tenant-id" not in call_args[1]["headers"]
|
||||
|
||||
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
||||
|
||||
# Verify the request body contains the expected data
|
||||
request_body = call_args[1]["json"]
|
||||
request_body = json.loads(call_args[1]["content"])
|
||||
assert "post" in request_body
|
||||
assert len(request_body["post"]) == 1 # Should contain one run
|
||||
assert len(request_body["post"]) == 1
|
||||
|
||||
# EXPECTED BODY
|
||||
expected_body = {
|
||||
|
|
@ -404,7 +406,7 @@ async def test_langsmith_key_based_logging():
|
|||
}
|
||||
|
||||
# Print both bodies for debugging
|
||||
actual_body = call_args[1]["json"]
|
||||
actual_body = json.loads(call_args[1]["content"])
|
||||
print("\nExpected body:")
|
||||
print(json.dumps(expected_body, indent=2))
|
||||
print("\nActual body:")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.langsmith import LangsmithLogger
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.types.integrations.langsmith import LangsmithQueueObject
|
||||
|
||||
|
||||
|
|
@ -219,6 +223,121 @@ class TestLangsmithLoggerInit:
|
|||
assert len(logger.log_queue) == 1
|
||||
|
||||
|
||||
class TestLangsmithBatchSerialization:
|
||||
async def _logger(self, transport_handler, tenant_id=None):
|
||||
logger = LangsmithLogger(
|
||||
langsmith_api_key="test-key",
|
||||
langsmith_project="test-project",
|
||||
langsmith_base_url="https://api.smith.langchain.com",
|
||||
langsmith_tenant_id=tenant_id,
|
||||
)
|
||||
if logger._flush_task is not None:
|
||||
logger._flush_task.cancel()
|
||||
handler = AsyncHTTPHandler()
|
||||
await handler.client.aclose()
|
||||
handler.client = httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(transport_handler)
|
||||
)
|
||||
logger.async_httpx_client = handler
|
||||
return logger
|
||||
|
||||
@staticmethod
|
||||
def _capturing_transport(captured):
|
||||
async def handle(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200, request=request, json={"ok": True})
|
||||
|
||||
return handle
|
||||
|
||||
def _queue(self, logger, extra):
|
||||
return [
|
||||
LangsmithQueueObject(
|
||||
data={"id": "run-1", "name": "LLMRun", "extra": extra},
|
||||
credentials=logger.default_credentials,
|
||||
)
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datetime_and_decimal_metadata_reach_langsmith_as_strings(self):
|
||||
captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer
|
||||
logger = await self._logger(self._capturing_transport(captured))
|
||||
logger.log_queue = self._queue(
|
||||
logger,
|
||||
{
|
||||
"created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
|
||||
"spend": Decimal("0.0042"),
|
||||
},
|
||||
)
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert len(captured) == 1, "batch was dropped instead of being sent"
|
||||
body = json.loads(captured[0].content)
|
||||
assert body["post"][0]["extra"] == {
|
||||
"created_at": "2026-01-02 03:04:05+00:00",
|
||||
"spend": "0.0042",
|
||||
}
|
||||
await logger.async_httpx_client.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nan_metadata_is_dropped_instead_of_shipping_invalid_json(self):
|
||||
captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer
|
||||
logger = await self._logger(self._capturing_transport(captured))
|
||||
logger.log_queue = self._queue(logger, {"score": float("nan")})
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert captured == [], (
|
||||
"nan metadata must abort the batch: a bare NaN token is invalid JSON and LangSmith rejects it"
|
||||
)
|
||||
await logger.async_httpx_client.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_declares_json_content_type(self):
|
||||
captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer
|
||||
logger = await self._logger(self._capturing_transport(captured))
|
||||
logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"})
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert captured[0].headers["content-type"] == "application/json", (
|
||||
"a content= body carries no implicit content type; LangSmith refuses it without this header"
|
||||
)
|
||||
assert captured[0].url.path.endswith("/api/v1/runs/batch")
|
||||
assert captured[0].headers["x-api-key"] == "test-key"
|
||||
assert "x-tenant-id" not in captured[0].headers
|
||||
await logger.async_httpx_client.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_id_is_forwarded_on_the_batch_request(self):
|
||||
captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer
|
||||
logger = await self._logger(
|
||||
self._capturing_transport(captured), tenant_id="tenant-1"
|
||||
)
|
||||
logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"})
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert captured[0].headers["x-tenant-id"] == "tenant-1"
|
||||
await logger.async_httpx_client.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langsmith_error_response_does_not_propagate(self):
|
||||
captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer
|
||||
|
||||
async def reject(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(422, request=request, text="bad run")
|
||||
|
||||
logger = await self._logger(reject)
|
||||
logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"})
|
||||
|
||||
await logger.async_send_batch()
|
||||
|
||||
assert len(captured) == 1, "the batch never left the process"
|
||||
await logger.async_httpx_client.client.aclose()
|
||||
|
||||
|
||||
class TestLangsmithPrepareLogData:
|
||||
"""Regression test for #24001: _prepare_log_data must inject
|
||||
usage_metadata into outputs so LangSmith's Cost column is populated."""
|
||||
|
|
@ -544,12 +663,10 @@ async def test_events_appended_during_flush_are_not_dropped():
|
|||
credentials=logger.default_credentials, data={"id": "late"}
|
||||
)
|
||||
|
||||
async def fake_post(
|
||||
url: str, json: dict[str, list[dict[str, str]]], headers: dict[str, str]
|
||||
) -> MagicMock:
|
||||
async def fake_post(url: str, content: str, headers: dict[str, str]) -> MagicMock:
|
||||
if not sent_batches:
|
||||
logger.log_queue.append(late_event)
|
||||
sent_batches.append(json["post"])
|
||||
sent_batches.append(json.loads(content)["post"])
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import pathlib
|
|||
import ssl
|
||||
import threading
|
||||
import weakref
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import certifi
|
||||
|
|
@ -23,6 +25,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
_get_httpx_client,
|
||||
get_ssl_configuration,
|
||||
)
|
||||
from litellm.types.llms.custom_http import VerifyTypes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1396,6 +1399,47 @@ async def test_finalizer_on_live_loop_disposes_foreign_loop_session_without_sche
|
|||
assert session.closed
|
||||
|
||||
|
||||
class _RetryClientHandler(AsyncHTTPHandler):
|
||||
def __init__(self, first: httpx.AsyncClient, retry: httpx.AsyncClient) -> None:
|
||||
self._retry_client: Final = retry
|
||||
super().__init__()
|
||||
self.client = first
|
||||
|
||||
def create_client(
|
||||
self,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
event_hooks: Mapping[str, list[Callable[..., object]]] | None = None,
|
||||
ssl_verify: VerifyTypes | None = None,
|
||||
shared_session: ClientSession | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
return self._retry_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"])
|
||||
async def test_connection_error_retry_forwards_content(method: str):
|
||||
captured: list[bytes] = [] # mutable-ok: async closure capture buffer
|
||||
|
||||
async def raise_connection_error(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.RemoteProtocolError("connection dropped", request=request)
|
||||
|
||||
async def capture_and_succeed(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request.content)
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
first: Final = httpx.AsyncClient(transport=httpx.MockTransport(raise_connection_error))
|
||||
retry: Final = httpx.AsyncClient(transport=httpx.MockTransport(capture_and_succeed))
|
||||
async with first, retry:
|
||||
handler: Final = _RetryClientHandler(first=first, retry=retry)
|
||||
|
||||
body = b'{"post": ["run1"]}'
|
||||
await getattr(handler, method)("https://api.example.com/runs/batch", content=body)
|
||||
|
||||
assert captured == [body], "the retried request must carry the same content= body"
|
||||
await handler.close()
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forward_proxy_server():
|
||||
"""Plain HTTP forward proxy that records the absolute URIs it is asked to fetch."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue