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"""