Logging
About 1170 wordsAbout 4 min
Spora uses a PSR-3 logger (Monolog) injected throughout the system. This document covers what gets logged, where, at which levels, and how to handle PII safely.
Configuration
Set the SPORA_LOG_LEVEL environment variable (in .env) to control verbosity:
SPORA_LOG_LEVEL=debug # Full traces, including tool arguments (⚠ may contain PII)
SPORA_LOG_LEVEL=info # Informational operational events
SPORA_LOG_LEVEL=warning # Default — only unexpected conditions
SPORA_LOG_LEVEL=error # Only failures and exceptionsThe default is warning. The level is mapped to Monolog’s Level enum (e.g. warning → Monolog\Level::Warning) and applied to the single StreamHandler registered in app/Core/container.php (LoggerInterface definition, lines 191–206). Messages below the configured level are discarded by the handler.
The logger channel name is spora (visible in Monolog’s JSON channel field) and the default log file is storage/spora.log. Set SPORA_LOG_PATH=stdout to stream records to stdout — useful when running under supervisord or Docker so logs flow into the container’s log driver instead of a bind-mounted file.
A separate storage/php.log file is not produced by Spora’s logger. In local development, it is the redirected stdout/stderr of the PHP built-in server started by bin/dev (bin/dev:32 does php -S localhost:8080 -t public/ > storage/php.log 2>&1). In production, php.log is not generated by Spora code — Spora does not configure the PHP error_log directive; if you need a PHP-error log in production, set it via your php.ini, supervisord, or container entrypoint.
What Gets Logged
Orchestrator — Tool Dispatch (app/Agents/Orchestrator.php)
Every call to safeExecute() (the single chokepoint for all tool execution) produces structured log entries.
| Event | Level | Fields |
|---|---|---|
| Tool is about to be called | DEBUG | tool, agent_id, task_id, arguments |
| Tool returned failure | ERROR | tool, agent_id, task_id, content |
| Tool threw an unhandled exception | ERROR | tool, agent_id, task_id, exception_class, message |
LLM Driver HTTP Requests (app/Drivers/)
Every HTTP request to an LLM provider (AnthropicCompatibleDriver, OpenAICompatibleDriver) is logged at DEBUG with request/response bodies included. Each driver makes explicit $this->logger?->debug('LLM Request (...)', [...]) and $this->logger?->debug('LLM Response (...)', [...]) calls inside its complete() method.
Email — Log Transport
When SPORA_MAIL_DRIVER=log, emails are logged (not sent) via LogTransport. Each logged entry includes to, from, and subject at info level.
PII Policy
Tool arguments are logged only at DEBUG level and never at ERROR.
Tool arguments frequently contain personally identifiable information (PII):
- Email addresses, message subjects, and body content (
emailtool —send_emailandread_inboxoperations) - Search queries that may include names, medical terms, or private intent (
tavily_search,serper_search) - Calendar event titles and descriptions (
calendartool —list_eventsandget_eventoperations) - URL contents fetched on behalf of a user (
read_url)
Rules
| Situation | Arguments logged? | Level |
|---|---|---|
| Normal tool dispatch | Yes | DEBUG only |
Tool returned success=false | No | ERROR |
| Tool threw an exception | No | ERROR |
In other words: tool arguments (potentially PII) are logged at DEBUG level only. Use SPORA_LOG_LEVEL=info or higher in production to suppress them entirely.
The content field on a failed ToolResult is safe to log at ERROR because it is produced by Spora’s own tool code (e.g. "Tavily API key is not configured") and does not echo back user input. Note that one tool — CalDavCalendarTool — is an exception: it logs a 200-character ASCII response_preview of the HTTP body at ERROR (full body still confined to DEBUG), since CalDAV error responses may carry event payloads the tool needs to diagnose.
Before enabling DEBUG logging in production
- Ensure log files are stored with restricted filesystem permissions (
chmod 640). - If logs are shipped to a third-party aggregator (Datadog, Sentry, Papertrail), verify your data-processing agreement covers PII in log data.
- Consider your data-retention obligations. DEBUG logs should be rotated aggressively (e.g. 7 days).
- Never enable
SPORA_LOG_LEVEL=debugon multi-tenant deployments where log access is shared across tenants.
Adding Logging to a New Tool
Tool classes receive an optional ?LoggerInterface $logger via constructor injection (the Orchestrator instantiates tools through PHP-DI, which supplies the LoggerInterface from the container when the constructor declares it). Use it for tool-internal errors that are not already captured by the Orchestrator’s safeExecute() wrapper.
final class MyTool extends AbstractTool
{
public function __construct(
private readonly ToolConfigService $configService,
private readonly ?LoggerInterface $logger = null, // nullable — always optional
) {}
public function execute(array $arguments, int $agentId, ?int $userId = null): ToolResult
{
try {
// ... do work ...
} catch (Throwable $e) {
// Log internal errors at the tool level for diagnostics.
// Do NOT include $arguments here — they may contain PII.
$this->logger?->error('MyTool: upstream call failed', [
'exception_class' => get_class($e),
'message' => $e->getMessage(),
]);
return new ToolResult(false, 'Service unavailable: ' . $e->getMessage());
}
}
}The Orchestrator’s safeExecute() will also log the ToolResult(false, ...) at ERROR level, so you get two complementary log entries: one with the tool’s internal context (from the tool itself) and one with the orchestration context (tool, agent_id, task_id).
Never log $arguments at WARNING or above. If you need argument context for debugging, log at DEBUG:
$this->logger?->debug('MyTool dispatch context', [
'agent_id' => $agentId,
'arguments' => $arguments, // ⚠ PII risk — DEBUG only
]);Best Practices
Use structured context arrays, not string interpolation.
// Good
$this->logger?->error('API call failed', ['status' => $code, 'tool' => 'tavily_search']);
// Bad — unstructured, hard to query in log aggregators
$this->logger?->error("API call to Tavily failed with status {$code}");Use the right level.
| Level | When to use |
|---|---|
debug | Full execution traces, argument dumps, HTTP request/response bodies. Only useful during active development or debugging a specific incident. |
info | Notable operational events — task lifecycle (resume, approval pause), Mercure publish, worker/scheduler start/stop, CSRF token rotation, log-driver mail. |
warning | Unexpected but recoverable situations that don’t require immediate action. |
error | Failures that prevent a tool or request from completing. Always logged — even in production. |
critical / alert / emergency | Reserved for system-level failures (database unreachable, out of memory). Not used by tools. |
Never swallow exceptions silently. If you catch a Throwable, either return a ToolResult(false, ...) so the Orchestrator logs it, or re-throw. Logging and swallowing together is acceptable only when you have a genuine fallback:
} catch (Throwable $e) {
$this->logger?->error('Cache miss — falling back to live fetch', ['message' => $e->getMessage()]);
// Then do the fallback, not just return silently.
}Match log levels to operator expectations. Operators watching ERROR logs expect to be paged. A tool returning ToolResult(false, 'API key not configured') is a legitimate error — the agent cannot complete its task. But a tool returning ToolResult(false, 'No results found') is a normal empty-result that should be warning or debug at most in the tool’s own logger, even though the Orchestrator will still log it at error (because it received success=false).
Consider having your tool return success=true with a “no results found” message when that is an expected, non-failure outcome — this avoids a spurious error log entry at the Orchestrator level.