From 5553b946e64f0d3961fec1497ec87591a9642f31 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Thu, 29 Jan 2026 13:21:43 -0600 Subject: [PATCH 1/3] Add logging support This change introduces both low-level logging endpoint access and a higher-level interface to write logs using python's standard logging facade. Also included are test cases and example code to help facilitate that testing. A callable mapper is provided in order to support users that may want to map logger's by name to more than a single fastly logging endpoint. --- Makefile | 2 +- examples/logging/log_app.py | 247 +++++++++++++++++++++++++++ examples/logging/pyproject.toml | 15 ++ examples/logging/uv.lock | 49 ++++++ fastly_compute/log.py | 286 ++++++++++++++++++++++++++++++++ tests/test_log.py | 244 +++++++++++++++++++++++++++ 6 files changed, 842 insertions(+), 1 deletion(-) create mode 100644 examples/logging/log_app.py create mode 100644 examples/logging/pyproject.toml create mode 100644 examples/logging/uv.lock create mode 100644 fastly_compute/log.py create mode 100644 tests/test_log.py diff --git a/Makefile b/Makefile index 48ffe80..314f89f 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ EXAMPLES_DIR := examples COMPUTE_WIT := wit/deps/fastly/compute.wit # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store +EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store logging # Default example for serve target EXAMPLE ?= bottle-app diff --git a/examples/logging/log_app.py b/examples/logging/log_app.py new file mode 100644 index 0000000..620fcd1 --- /dev/null +++ b/examples/logging/log_app.py @@ -0,0 +1,247 @@ +"""Logging example application. + +Demonstrates Fastly Logging API usage with test endpoints. +""" + +import json +import logging +import traceback +from typing import Any + +from bottle import Bottle, response + +from fastly_compute.log import FastlyLogHandler, LogEndpoint +from fastly_compute.wsgi import WsgiHttpIncoming + +app = Bottle() + + +def json_response(data: dict[str, Any], status_code: int = 200) -> str: + """Create a JSON response.""" + response.content_type = "application/json" + response.status = status_code + return json.dumps(data, indent=2) + + +def handle_request(handler): + """Decorator to handle common request/response patterns.""" + + def wrapper(*args, **kwargs): + try: + result = handler(*args, **kwargs) + return json_response(result) + except Exception as e: + return json_response( + { + "error": repr(e), + "error_type": type(e).__name__, + "traceback": traceback.format_exc(), + }, + status_code=500, + ) + + return wrapper + + +# Direct API tests +@app.route("/test/write//") +@handle_request +def test_write(endpoint_name, message): + """Test writing a string message.""" + endpoint = LogEndpoint.open(endpoint_name) + endpoint.write(message) + return {"written": True} + + +@app.route("/test/write-bytes/") +@handle_request +def test_write_bytes(endpoint_name): + """Test writing bytes directly.""" + endpoint = LogEndpoint.open(endpoint_name) + endpoint.write(b"Binary log data: \x00\x01\x02\x03") + return {"written": True} + + +@app.route("/test/write-empty/") +@handle_request +def test_write_empty(endpoint_name): + """Test writing an empty string.""" + endpoint = LogEndpoint.open(endpoint_name) + endpoint.write("") + return {"written": True} + + +@app.route("/test/context-manager/") +@handle_request +def test_context_manager(endpoint_name): + """Test using endpoint as a context manager.""" + with LogEndpoint.open(endpoint_name) as endpoint: + endpoint.write("Message from context manager") + return {"success": True} + + +# Python logging integration tests +@app.route("/test/logging///") +@handle_request +def test_logging(endpoint_name, level, message): + """Test standard Python logging integration.""" + logger = logging.getLogger(f"test_{endpoint_name}") + logger.setLevel(logging.DEBUG) + + # Clear any existing handlers + logger.handlers.clear() + + # Add Fastly handler with new API + handler = FastlyLogHandler(default_endpoint=endpoint_name) + handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logger.addHandler(handler) + + # Log at the requested level + log_func = getattr(logger, level.lower()) + log_func(message) + + # Clean up + handler.close() + + return {"logged": True} + + +@app.route("/test/logging-extra/") +@handle_request +def test_logging_extra(endpoint_name): + """Test logging with extra fields.""" + logger = logging.getLogger(f"test_extra_{endpoint_name}") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + handler = FastlyLogHandler(default_endpoint=endpoint_name) + handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s - user=%(user_id)s" + ) + ) + logger.addHandler(handler) + + logger.info("User action", extra={"user_id": 12345}) + + handler.close() + + return {"logged": True} + + +@app.route("/test/logging-multiple/") +@handle_request +def test_logging_multiple(endpoint_name): + """Test logging multiple messages.""" + logger = logging.getLogger(f"test_multiple_{endpoint_name}") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + handler = FastlyLogHandler(default_endpoint=endpoint_name) + logger.addHandler(handler) + + count = 5 + for i in range(count): + logger.info(f"Log message {i + 1}") + + handler.close() + + return {"count": count} + + +@app.route("/test/json-log/") +@handle_request +def test_json_log(endpoint_name): + """Test structured JSON logging.""" + logger = logging.getLogger(f"test_json_{endpoint_name}") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + handler = FastlyLogHandler(default_endpoint=endpoint_name) + logger.addHandler(handler) + + # Create structured log message + log_data = { + "event": "request_processed", + "status": 200, + "duration_ms": 42, + "user_id": 12345, + "path": "/api/data", + } + logger.info(json.dumps(log_data)) + + handler.close() + + return {"logged": True} + + +# Endpoint routing tests +@app.route("/test/logging-with-mapper///") +@handle_request +def test_logging_with_mapper(logger_name, level, message): + """Test logging with endpoint mapper function.""" + logger = logging.getLogger(logger_name) + logger.setLevel(logging.DEBUG) + logger.handlers.clear() + + # Define a mapper that routes based on logger name + def endpoint_mapper(name: str) -> str | None: + if name.startswith("api"): + return "api-logs" + # Return None to use default + return None + + handler = FastlyLogHandler( + default_endpoint="default-logs", endpoint_mapper=endpoint_mapper + ) + handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logger.addHandler(handler) + + # Log at the requested level + log_func = getattr(logger, level.lower()) + log_func(message) + + handler.close() + + return {"logged": True} + + +@app.route("/test/logging-with-dict///") +@handle_request +def test_logging_with_dict(logger_name, level, message): + """Test logging with dict-based endpoint mapper.""" + logger = logging.getLogger(logger_name) + logger.setLevel(logging.DEBUG) + logger.handlers.clear() + + # Use a dict for simple mapping + endpoint_map = { + "api": "api-logs", + "worker": "worker-logs", + "background": "worker-logs", + } + + handler = FastlyLogHandler( + default_endpoint="default-logs", + endpoint_mapper=lambda name: endpoint_map.get(name.split(".")[0]), + ) + handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logger.addHandler(handler) + + # Log at the requested level + log_func = getattr(logger, level.lower()) + log_func(message) + + handler.close() + + return {"logged": True} + + +# Create the HTTP handler for Fastly Compute +HttpIncoming = WsgiHttpIncoming(app) diff --git a/examples/logging/pyproject.toml b/examples/logging/pyproject.toml new file mode 100644 index 0000000..0f8504f --- /dev/null +++ b/examples/logging/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "logging" +version = "0.1.0" +description = "Fastly Compute example demonstrating Logging API usage" +requires-python = ">=3.12" +dependencies = [ + "bottle>=0.12.25", + "fastly-compute", +] + +[tool.uv.sources] +fastly-compute = { path = "../../", editable = true } + +[tool.fastly-compute] +entry = "log_app" diff --git a/examples/logging/uv.lock b/examples/logging/uv.lock new file mode 100644 index 0000000..d6756f8 --- /dev/null +++ b/examples/logging/uv.lock @@ -0,0 +1,49 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + +[[package]] +name = "fastly-compute" +version = "0.1.0" +source = { editable = "../../" } + +[package.metadata] +requires-dist = [ + { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, + { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, + { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0,<9.0.0" }, + { name = "requests", marker = "extra == 'test'", specifier = ">=2.32.5,<3.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.11,<0.13.0" }, + { name = "syrupy", marker = "extra == 'test'", specifier = "==5.0.0" }, + { name = "tomli-w", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, +] +provides-extras = ["test", "dev", "examples"] + +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.11.5" }] + +[[package]] +name = "logging" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bottle" }, + { name = "fastly-compute" }, +] + +[package.metadata] +requires-dist = [ + { name = "bottle", specifier = ">=0.12.25" }, + { name = "fastly-compute", editable = "../../" }, +] diff --git a/fastly_compute/log.py b/fastly_compute/log.py new file mode 100644 index 0000000..da2dcd9 --- /dev/null +++ b/fastly_compute/log.py @@ -0,0 +1,286 @@ +"""Logging API for Fastly Compute. + +This module provides access to Fastly logging endpoints, allowing you to send +logs to configured Real-Time Log Streaming endpoints. + +Example:: + + from fastly_compute import LogEndpoint + + # Direct usage + endpoint = LogEndpoint.open("my_logs") + endpoint.write("Hello from Fastly Compute!") + + # Using Python standard logging + import logging + from fastly_compute import FastlyLogHandler + + logger = logging.getLogger("my_app") + logger.setLevel(logging.INFO) + logger.addHandler(FastlyLogHandler("my_logs")) + + logger.info("Request processed", extra={"user_id": 123}) +""" + +import logging +from collections.abc import Callable +from typing import Self + +from wit_world.imports import log as wit_log +from wit_world.imports.types import OpenError + +from fastly_compute.exceptions import FastlyError, remap_wit_errors + + +class LogEndpointError(FastlyError): + """Base exception for all log endpoint errors.""" + + +class LogEndpointNotFoundError(LogEndpointError): + """The requested log endpoint does not exist.""" + + +class LogEndpointInvalidNameError(LogEndpointError): + """The log endpoint name is invalid.""" + + +class LogEndpoint: + """Interface to a Fastly logging endpoint. + + Logging endpoints send log data to configured Real-Time Log Streaming + destinations. Configure endpoints through the Fastly web interface or API. + + Example:: + + with LogEndpoint.open("my_logs") as endpoint: + endpoint.write("Application started") + endpoint.write(b"Binary log data") + """ + + def __init__(self, endpoint: wit_log.Endpoint): + """Private constructor. Use LogEndpoint.open() instead.""" + self._endpoint = endpoint + + @classmethod + @remap_wit_errors( + { + OpenError.NOT_FOUND: LogEndpointNotFoundError, + OpenError.INVALID_SYNTAX: LogEndpointInvalidNameError, + OpenError.NAME_TOO_LONG: LogEndpointInvalidNameError, + OpenError.RESERVED: LogEndpointInvalidNameError, + } + ) + def open(cls, name: str) -> "LogEndpoint": + r"""Open a logging endpoint by name. + + Names are case sensitive. Calling open() with a name that doesn't + correspond to any logging endpoint available in your service will + still return a usable endpoint, and writes to that endpoint will + succeed. Refer to your service dashboard to diagnose missing log events. + + Currently, the conditions on an endpoint name are: + - It must not be empty. + - It must not contain newlines (\n) or colons (:). + - It must not be `stdout` or `stderr`, which are reserved for debugging. + + :param name: The name of the logging endpoint + :return: LogEndpoint instance + :raises LogEndpointNotFoundError: If the endpoint doesn't exist + :raises LogEndpointInvalidNameError: If the name is invalid or too long + + Example:: + + endpoint = LogEndpoint.open("my_logs") + """ + endpoint = wit_log.Endpoint.open(name) + return cls(endpoint) + + def write(self, msg: bytes | str) -> None: + """Write data to the logging endpoint. + + Each call to write() with a non-empty message produces a single log event. + + :param msg: The message to write (bytes or string). Strings are UTF-8 encoded. + + Example:: + + endpoint = LogEndpoint.open("my_logs") + endpoint.write("Text message") + endpoint.write(b"Binary data") + """ + if isinstance(msg, str): + msg = msg.encode("utf-8") + self._endpoint.write(msg) + + def close(self) -> None: + """Explicitly close the logging endpoint, releasing its resources. + + This is called automatically when using the endpoint as a context + manager. If not called explicitly, resources will eventually be freed + by the garbage collector. + + Note: Attempting to use the endpoint after it is closed will result + in a trap. + """ + self._endpoint.__exit__(None, None, None) + + def __enter__(self) -> Self: + """Context manager entry. + + Allows use of LogEndpoint in a 'with' statement. + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self._endpoint.__exit__(exc_type, exc_val, exc_tb) + + +class FastlyLogHandler(logging.Handler): + """A logging handler that sends logs to a Fastly endpoint. + + This handler integrates with Python's standard logging framework, + allowing you to use familiar logging patterns with Fastly's + Real-Time Log Streaming. + + The handler supports routing logs from different loggers to different + endpoints using an endpoint_mapper function. + + Example:: + + import logging + from fastly_compute import FastlyLogHandler + + # Simple: single endpoint for all loggers + handler = FastlyLogHandler("my_logs") + logger = logging.getLogger("my_app") + logger.addHandler(handler) + + # Advanced: route different loggers to different endpoints + def route_logs(logger_name: str) -> str: + if logger_name.startswith("api"): + return "api-logs" + elif logger_name.startswith("worker"): + return "worker-logs" + return "app-logs" # default + + handler = FastlyLogHandler(endpoint_mapper=route_logs) + + # Or use a dict for simple mappings + endpoint_map = {"api": "api-logs", "worker": "worker-logs"} + handler = FastlyLogHandler( + "default", + endpoint_mapper=lambda name: endpoint_map.get(name, "app-logs") + ) + + # Use logger + logger.info("Request received", extra={"user_id": 123}) + logger.error("Error processing request", exc_info=True) + """ + + def __init__( + self, + default_endpoint: str | None, + endpoint_mapper: Callable[[str], str | None] | None = None, + level=logging.NOTSET, + ): + """Initialize the handler. + + :param default_endpoint: The default endpoint name (used when no mapper provided) + :param endpoint_mapper: Optional callable that maps logger name to endpoint name. + Signature: (logger_name: str) -> endpoint_name: str + :param level: Minimum logging level to handle (default: NOTSET) + :raises ValueError: If neither default_endpoint nor endpoint_mapper is provided + + Example:: + + # Simple: single endpoint + handler = FastlyLogHandler("my_logs") + + # Advanced: route by logger name + handler = FastlyLogHandler( + endpoint_mapper=lambda name: f"{name}-logs" + ) + + # With fallback + handler = FastlyLogHandler( + default_endpoint="app-logs", + endpoint_mapper=lambda name: "api-logs" if "api" in name else None + ) + """ + super().__init__(level) + + if default_endpoint is None and endpoint_mapper is None: + raise ValueError( + "Either default_endpoint or endpoint_mapper must be provided" + ) + + self._default_endpoint: str | None = default_endpoint + self._endpoint_mapper: Callable[[str], str | None] | None = endpoint_mapper + self._endpoints: dict[str, LogEndpoint] = {} # Cache opened endpoints + + def _get_endpoint(self, logger_name: str) -> LogEndpoint: + """Get or create an endpoint for the given logger name. + + :param logger_name: Name of the logger + :return: LogEndpoint instance + """ + # Determine which endpoint to use + endpoint_name: str | None + if self._endpoint_mapper is not None: + endpoint_name = self._endpoint_mapper(logger_name) + # If mapper returns None, fall back to default + if endpoint_name is None: + endpoint_name = self._default_endpoint + else: + endpoint_name = self._default_endpoint + + if endpoint_name is None: + raise ValueError( + f"No endpoint determined for logger '{logger_name}' " + "(mapper returned None and no default_endpoint set)" + ) + + # Return cached endpoint if available + if endpoint_name in self._endpoints: + return self._endpoints[endpoint_name] + + # Open new endpoint and cache it + endpoint = LogEndpoint.open(endpoint_name) + self._endpoints[endpoint_name] = endpoint + return endpoint + + def emit(self, record: logging.LogRecord): + """Emit a record to the Fastly logging endpoint. + + This method is called automatically by the logging framework. + You should not need to call it directly. + + The endpoint is determined by calling endpoint_mapper with the + logger name, or using the default_endpoint. + + :param record: The log record to emit + """ + try: + endpoint = self._get_endpoint(record.name) + msg = self.format(record) + endpoint.write(msg) + except Exception: + # Handler errors should not propagate to the application; this should + # rare but we would rather log a deferred format problem or similar as an error + # rather than crashing the application. + self.handleError(record) + + def close(self): + """Close the handler and release all endpoint resources. + + This is called automatically when the handler is garbage collected + or when logging.shutdown() is called. + """ + try: + # Close all cached endpoints + for endpoint in self._endpoints.values(): + endpoint.close() + self._endpoints.clear() + finally: + super().close() diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..5ffa6de --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,244 @@ +"""Integration tests for Logging functionality.""" + +import re + +from fastly_compute.testing import ViceroyTestBase + + +class TestLogging(ViceroyTestBase): + """Logging integration tests.""" + + WASM_FILE = "build/logging.composed.wasm" + + VICEROY_CONFIG = { + "local_server": { + "log_endpoints": { + "test-logs": {}, + "json-logs": {}, + "api-logs": {}, + "worker-logs": {}, + "default-logs": {}, + } + } + } + + def assert_success(self, response, expected_data=None): + """Assert that the response was successful. + + Args: + response: The HTTP response + expected_data: Optional dict of expected response data + """ + assert response.status_code == 200 + data = response.json() + if expected_data: + for key, value in expected_data.items(): + assert data[key] == value + return data + + def assert_error(self, response, error_type): + """Assert that the response contains an error of the expected type. + + Args: + response: The HTTP response + error_type: Expected error type name (e.g., "LogEndpointInvalidNameError") + """ + assert response.status_code == 500 + data = response.json() + assert data["error_type"] == error_type + + def _get_logs_for_endpoint(self, endpoint_name): + """Get all log messages for a specific endpoint from viceroy output. + + Args: + endpoint_name: Name of the log endpoint + + Returns: + List of log messages (without the endpoint prefix) + """ + log_prefix = f"{endpoint_name} :: " + logs = [] + for line in self.server.output_lines: + if log_prefix in line: + log_message = line.split(log_prefix, 1)[1] + logs.append(log_message) + return logs + + def assert_log_message(self, endpoint_name, expected_message): + """Assert that an exact log message was written to viceroy stdout. + + Args: + endpoint_name: Name of the log endpoint + expected_message: Exact message expected + """ + logs = self._get_logs_for_endpoint(endpoint_name) + + if expected_message in logs: + return # Found it! + + # Not found - provide helpful error message + error_msg = ( + f"Expected exact log message not found.\n" + f"Expected: {expected_message}\n" + f"Actual logs for {endpoint_name}:\n " + + ("\n ".join(logs) if logs else "(no logs)") + ) + raise AssertionError(error_msg) + + def assert_log_matches(self, endpoint_name, pattern): + """Assert that a log message matching the pattern was written to viceroy stdout. + + Args: + endpoint_name: Name of the log endpoint + pattern: Regex pattern to match + """ + logs = self._get_logs_for_endpoint(endpoint_name) + + for log_message in logs: + if re.search(pattern, log_message): + return # Found it! + + # Not found - provide helpful error message + error_msg = ( + f"Expected log pattern not found.\n" + f"Pattern: {pattern}\n" + f"Actual logs for {endpoint_name}:\n " + + ("\n ".join(logs) if logs else "(no logs)") + ) + raise AssertionError(error_msg) + + def assert_log_count(self, endpoint_name, expected_count, pattern=None): + """Assert that a specific number of log messages were written. + + Args: + endpoint_name: Name of the log endpoint + expected_count: Expected number of log messages + pattern: Optional regex pattern to filter logs + """ + logs = self._get_logs_for_endpoint(endpoint_name) + + if pattern: + matching_logs = [log for log in logs if re.search(pattern, log)] + actual_count = len(matching_logs) + if actual_count == expected_count: + return # Success + + error_msg = ( + f"Expected {expected_count} log messages matching pattern, " + f"found {actual_count}.\n" + f"Pattern: {pattern}\n" + f"Matching logs:\n " + + ("\n ".join(matching_logs) if matching_logs else "(no matches)") + ) + else: + actual_count = len(logs) + if actual_count == expected_count: + return # Success + + error_msg = ( + f"Expected {expected_count} log messages, found {actual_count}.\n" + f"All logs for {endpoint_name}:\n " + + ("\n ".join(logs) if logs else "(no logs)") + ) + + raise AssertionError(error_msg) + + # Writing messages + + def test_write_string(self): + """Test writing a string message.""" + response = self.get("/test/write/test-logs/Hello%20World") + self.assert_success(response, {"written": True}) + self.assert_log_message("test-logs", "Hello%20World") + + def test_write_bytes(self): + """Test writing bytes directly.""" + response = self.get("/test/write-bytes/test-logs") + self.assert_success(response, {"written": True}) + # Binary data with null bytes - verify at least one log was written + self.assert_log_matches("test-logs", r"Binary log data") + + def test_write_unicode(self): + """Test writing unicode characters.""" + response = self.get( + "/test/write/test-logs/Hello%20%E4%B8%96%E7%95%8C%20%F0%9F%8C%8D" + ) + self.assert_success(response, {"written": True}) + self.assert_log_message( + "test-logs", "Hello%20%E4%B8%96%E7%95%8C%20%F0%9F%8C%8D" + ) + + def test_write_empty_string(self): + """Test writing an empty string (produces no log event per spec).""" + response = self.get("/test/write-empty/test-logs") + self.assert_success(response, {"written": True}) + # Per spec: "Each call to write with a non-empty message produces a single log event" + # Empty string shouldn't produce a log, but we can't easily verify this + # since we share the endpoint with other tests. Just verify the API succeeds. + + def test_context_manager(self): + """Test using endpoint as a context manager.""" + response = self.get("/test/context-manager/test-logs") + self.assert_success(response, {"success": True}) + self.assert_log_message("test-logs", "Message from context manager") + + # Python logging integration + + def test_standard_logging_integration(self): + """Test integration with Python standard logging.""" + response = self.get("/test/logging/test-logs/INFO/Test%20message") + self.assert_success(response, {"logged": True}) + # The log should include timestamp, logger name, level, and message + self.assert_log_matches("test-logs", r"INFO.*Test%20message") + + def test_logging_with_extra_fields(self): + """Test logging with extra fields.""" + response = self.get("/test/logging-extra/test-logs") + self.assert_success(response, {"logged": True}) + # Should include the user_id in the formatted message + self.assert_log_matches("test-logs", r"user=12345") + + def test_logging_multiple_messages(self): + """Test logging multiple messages in sequence.""" + response = self.get("/test/logging-multiple/test-logs") + self.assert_success(response, {"count": 5}) + # Should have exactly 5 log messages with "Log message" in them + self.assert_log_count("test-logs", 5, pattern=r"Log message") + + def test_logging_all_levels(self): + """Test logging at all standard levels.""" + for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + response = self.get(f"/test/logging/test-logs/{level}/Test%20at%20{level}") + self.assert_success(response, {"logged": True}) + self.assert_log_matches("test-logs", rf"{level}.*Test%20at%20{level}") + + def test_json_structured_logging(self): + """Test structured logging with JSON format.""" + response = self.get("/test/json-log/json-logs") + self.assert_success(response, {"logged": True}) + # Should contain JSON with the expected fields + self.assert_log_matches("json-logs", r'"event":\s*"request_processed"') + self.assert_log_matches("json-logs", r'"user_id":\s*12345') + + # Endpoint routing tests + + def test_logging_with_mapper_function(self): + """Test endpoint routing using a mapper function.""" + response = self.get("/test/logging-with-mapper/api.requests/INFO/API%20request") + self.assert_success(response, {"logged": True}) + # Should route to api-logs based on logger name + self.assert_log_matches("api-logs", r"INFO.*API%20request") + + def test_logging_with_mapper_fallback(self): + """Test endpoint routing with mapper fallback to default.""" + response = self.get("/test/logging-with-mapper/unknown/INFO/Unknown%20logger") + self.assert_success(response, {"logged": True}) + # Should fall back to default-logs + self.assert_log_matches("default-logs", r"INFO.*Unknown%20logger") + + def test_logging_with_dict_mapper(self): + """Test endpoint routing using a dict-based mapper.""" + response = self.get("/test/logging-with-dict/worker/INFO/Worker%20task") + self.assert_success(response, {"logged": True}) + # Should route to worker-logs via dict lookup + self.assert_log_matches("worker-logs", r"INFO.*Worker%20task") From c8a93652ac56faeb871d73c2e04a379685b29372 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 18 Feb 2026 13:09:53 -0600 Subject: [PATCH 2/3] logging: update to use new exceptions hierarchy --- fastly_compute/log.py | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/fastly_compute/log.py b/fastly_compute/log.py index da2dcd9..a550a23 100644 --- a/fastly_compute/log.py +++ b/fastly_compute/log.py @@ -27,21 +27,6 @@ from typing import Self from wit_world.imports import log as wit_log -from wit_world.imports.types import OpenError - -from fastly_compute.exceptions import FastlyError, remap_wit_errors - - -class LogEndpointError(FastlyError): - """Base exception for all log endpoint errors.""" - - -class LogEndpointNotFoundError(LogEndpointError): - """The requested log endpoint does not exist.""" - - -class LogEndpointInvalidNameError(LogEndpointError): - """The log endpoint name is invalid.""" class LogEndpoint: @@ -62,15 +47,7 @@ def __init__(self, endpoint: wit_log.Endpoint): self._endpoint = endpoint @classmethod - @remap_wit_errors( - { - OpenError.NOT_FOUND: LogEndpointNotFoundError, - OpenError.INVALID_SYNTAX: LogEndpointInvalidNameError, - OpenError.NAME_TOO_LONG: LogEndpointInvalidNameError, - OpenError.RESERVED: LogEndpointInvalidNameError, - } - ) - def open(cls, name: str) -> "LogEndpoint": + def open(cls, name: str) -> Self: r"""Open a logging endpoint by name. Names are case sensitive. Calling open() with a name that doesn't @@ -85,8 +62,9 @@ def open(cls, name: str) -> "LogEndpoint": :param name: The name of the logging endpoint :return: LogEndpoint instance - :raises LogEndpointNotFoundError: If the endpoint doesn't exist - :raises LogEndpointInvalidNameError: If the name is invalid or too long + :raises ~fastly_compute.exceptions.types.open_error.NotFound: If the endpoint doesn't exist or can't be created. + :raises ~fastly_compute.exceptions.types.open_error.NameTooLong: If the name is too long. + :raises ~fastly_compute.exceptions.types.open_error.InvalidSyntax: If the name is invalid. Example:: @@ -127,12 +105,17 @@ def close(self) -> None: def __enter__(self) -> Self: """Context manager entry. - Allows use of LogEndpoint in a 'with' statement. + Allows use of resource in a 'with' statement. """ return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" + """Context manager exit. + + Use of the context manager will free up the underlying host resource on + exit. Referencing the resource after context manager exit will result in + a trap. + """ self._endpoint.__exit__(exc_type, exc_val, exc_tb) From ce9874115a6d249a2ce81f66e3b3745b8d4ca096 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 18 Feb 2026 13:12:49 -0600 Subject: [PATCH 3/3] Fix errant assumption that example directory contents Here, there was an assumption that examples/logging/logging.py would exist. It doesn't as this created a namespace conflict; just remove the unecessary check. --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 314f89f..8707989 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,6 @@ $(STUBS_DIR): $(COMPUTE_WIT) $(BUILD_DIR)/%.composed.wasm: wit/viceroy.wit wit/deps/fastly/compute.wit fastly_compute/wsgi.py fastly_compute/runtime_patching/patches.py | $(BUILD_DIR) $(STUBS_DIR) @echo "Building $* example with fastly-compute-py..." @test -d $(EXAMPLES_DIR)/$* || (echo "Error: Example directory $(EXAMPLES_DIR)/$* not found" && exit 1) - @test -f $(EXAMPLES_DIR)/$*/$*.py || (echo "Error: Example file $(EXAMPLES_DIR)/$*/$*.py not found" && exit 1) cd $(EXAMPLES_DIR)/$* && $(FASTLY_COMPUTE_PY) build --output ../../$@ # The script that writes the exceptions and the patches always rewrites