diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fb2dba0..cb830f4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,7 @@ # Supervaizer Changelog > **Created:** 2025-08-05 -> **Updated:** 2026-05-19 +> **Updated:** 2026-05-20 All notable changes to this project will be documented in this file. @@ -11,19 +11,32 @@ All notable changes to this project will be documented in this file. - Review and test feature/data-persistence - Complete feature/smart-install implementation -- Fix receive_human_input -- When AgentMethodField returns its value (in the kwargs of job_start), the value should be casted in the appropriate type : - - example: here the 'How many times to say hello' is supposed to be an 'int'. - agent_simple:job_start:74 - AGENT ExampleAgent: Received kwargs: {'action': 'start', 'fields': {'How many times to say hello': '3'}, 'context': JobContext(workspace_id='odm', job_id='01KGM75NQ76AWBAXHXERW8FKHW', started_by='alp', started_at=datetime.datetime(2026, 2, 4, 11, 39, 0, 712598, tzinfo=TzInfo(0)), mission_id='01KGG50ZMFYMHG9N5FGCACF0XA', mission_name='Operate Agent Hello World AI Agent', mission_context=None, job_instructions=JobInstructions(max_cases=None, max_duration=None, max_cost=None, stop_on_warning=False, stop_on_error=True, job_start_time=None)), 'agent_parameters': [{'name': 'SIMPLE AGENT PARAMETER', 'team_id': 2, 'description': 'Setup agent parameter in this workspace', 'is_environment': True, 'value': '123456', 'is_secret': False, 'is_required': False}, {'name': 'SIMPLE AGENT SECRET', 'team_id': 2, 'description': 'Setup agent secret in this workspace', 'is_environment': True, 'value': '123456', 'is_secret': True, 'is_required': False}]} ## [Unreleased] +### Changed + +- **Cloud Logging structured output** — `SUPERVAIZER_LOG_FORMAT=json` now switches controller stderr logs to newline-delimited JSON with Cloud Logging-compatible `severity` plus bound fields such as access-denial `path`, `reason`, and truncated `key_preview`; local text logging remains the default. + +### Tests + +- `tests/test_common.py` — structured JSON log output for API access-denial records +- `just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 658 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 92s | + ## [1.1.0] - 2026-05-19 ### Added - **Workspace agent authorization** — Studio-signed Ed25519 workspace authorization tokens on `X-Supervaize-Workspace-Authorization`; the SDK verifies JWKS-backed tokens and exposes `V2VerifiedWorkspaceContext` for handlers. Workspace and tenant slugs remain routing hints only. -- **Workspace binding protocol** — Agents can declare optional `workspace_binding` metadata with bootstrap `workspace_binding.options`, `workspace_binding.create`, and the `workspace_binding.create` surface so Studio can bind an agent-side record before a Workspace Agent Grant exists. +- **Workspace binding rotocol** — Agents can declare optional `workspace_binding` metadata with bootstrap `workspace_binding.options`, `workspace_binding.create`, and the `workspace_binding.create` surface so Studio can bind an agent-side record before a Workspace Agent Grant exists. - **Workspace authorization docs** — `docs/2026_05_WORKSPACE_AGENT_GRANTS.md` plus workspace authorization and binding bootstrap rules in `docs/2026_05_PROTOCOLS.md` and `docs/2026_05_SUPERVAIZER_v2.md`. ### Changed diff --git a/src/supervaizer/common.py b/src/supervaizer/common.py index 32d5b25..7aefeaa 100644 --- a/src/supervaizer/common.py +++ b/src/supervaizer/common.py @@ -14,9 +14,10 @@ import base64 import json import os +import sys import traceback from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, TextIO, TypeVar import demjson3 from cryptography.hazmat.primitives import hashes @@ -28,6 +29,57 @@ log = logger.bind(module="supervaize") T = TypeVar("T") +STRUCTURED_LOG_FORMAT_ENV = "SUPERVAIZER_LOG_FORMAT" +STRUCTURED_LOG_FORMAT_JSON = "json" +_DEFAULT_LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS}|" + " {level} | {message}" +) + + +def structured_logging_enabled() -> bool: + """Return whether Supervaizer should emit newline-delimited JSON logs.""" + return ( + os.getenv(STRUCTURED_LOG_FORMAT_ENV, "").strip().lower() + == STRUCTURED_LOG_FORMAT_JSON + ) + + +def configure_controller_logging( + log_level: str, + *, + sink: TextIO = sys.stderr, +) -> int: + """Configure Supervaizer controller logs for local text or Cloud Logging JSON.""" + log.remove() + if structured_logging_enabled(): + return log.add( + lambda message: _write_structured_log(message.record, sink), + level=log_level, + ) + return log.add( + sink, + colorize=True, + format=_DEFAULT_LOG_FORMAT, + level=log_level, + ) + + +def _write_structured_log(record: dict[str, Any], sink: TextIO) -> None: + payload: dict[str, Any] = { + "severity": record["level"].name, + "message": record["message"], + "timestamp": record["time"].isoformat(), + "logger": record["name"], + "module": record["module"], + "function": record["function"], + "line": record["line"], + } + for key, value in record["extra"].items(): + target_key = key if key not in payload else f"extra_{key}" + payload[target_key] = value + sink.write(json.dumps(payload, default=str, separators=(",", ":")) + "\n") + sink.flush() def is_local_mode() -> bool: @@ -220,13 +272,20 @@ def dict(self) -> dict[str, Any]: def log_access_denied_tailscale(ip: str, path: str, reason: str) -> None: # <-- ADDED """Log a Tailscale access denial with structured fields.""" - log.warning(f"[access:tailscale] denied ip={ip!r} path={path!r} reason={reason!r}") + log.bind(access_type="tailscale", ip=ip, path=path, reason=reason).warning( + f"[access:tailscale] denied ip={ip!r} path={path!r} reason={reason!r}" + ) def log_access_denied_api(key: str | None, path: str, reason: str) -> None: # <-- ADDED """Log an API-key access denial; key is truncated to avoid leaking secrets.""" - key_preview = (key[:6] + "…") if key and len(key) > 6 else (key or "") - log.warning( + key_preview = f"{key[:6]}..." if key and len(key) > 6 else (key or "") + log.bind( + access_type="api", + key_preview=key_preview, + path=path, + reason=reason, + ).warning( f"[access:api] denied key={key_preview!r} path={path!r} reason={reason!r}" ) diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 37f3e36..6097eb5 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -11,15 +11,14 @@ # https://mozilla.org/MPL/2.0/. import asyncio -from hashlib import sha256 import os import secrets -import sys import time import uuid from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from datetime import datetime # <-- REMOVED: Path (no longer needed) +from hashlib import sha256 from typing import Any, ClassVar, TypeVar, cast from urllib.parse import urlunparse @@ -45,6 +44,7 @@ ApiResult, ApiSuccess, SvBaseModel, + configure_controller_logging, decrypt_value, encrypt_value, is_local_mode, @@ -735,13 +735,7 @@ def registration_info(self) -> dict[str, Any]: def launch(self, log_level: str | None = "INFO") -> None: if log_level: - log.remove() - log.add( - sys.stderr, - colorize=True, - format="{time:YYYY-MM-DD HH:mm:ss.SSS}| {level} | {message}", - level=log_level, - ) + configure_controller_logging(log_level) # Add log handler for admin streaming if API key is enabled if self.api_key: diff --git a/tests/test_common.py b/tests/test_common.py index 7a16b94..99093e2 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -12,6 +12,7 @@ import json +from io import StringIO from typing import Any import pytest @@ -23,8 +24,12 @@ ApiError, ApiSuccess, SvBaseModel, + STRUCTURED_LOG_FORMAT_ENV, + configure_controller_logging, decrypt_value, encrypt_value, + log, + log_access_denied_api, singleton, ) @@ -203,6 +208,29 @@ def __init__(self, status_code: int, response_text: str) -> None: assert error.log_message == "❌ error : " +def test_configure_controller_logging_outputs_cloud_logging_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Structured logging emits JSON with Cloud Logging severity and queryable fields.""" + monkeypatch.setenv(STRUCTURED_LOG_FORMAT_ENV, "json") + output = StringIO() + sink_id = configure_controller_logging("INFO", sink=output) + try: + log_access_denied_api("abcdef123456", "/a2a", "invalid key") + finally: + log.remove(sink_id) + + payload = json.loads(output.getvalue()) + assert payload["severity"] == "WARNING" + assert payload["message"] == ( + "[access:api] denied key='abcdef...' path='/a2a' reason='invalid key'" + ) + assert payload["access_type"] == "api" + assert payload["key_preview"] == "abcdef..." + assert payload["path"] == "/a2a" + assert payload["reason"] == "invalid key" + + def test_singleton() -> None: """Test singleton decorator"""