Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions src/ian/services/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ian.config import DISCORD_BOT_TOKEN, DISCORD_LOG_CHANNEL_ID
from ian.domain.reminders import get_valid_bound_members
from ian.services import discord_api
from ian.utils.console import eprint
from ian.utils.logging import log_event


LOG_CHANNEL_ID = DISCORD_LOG_CHANNEL_ID
Expand All @@ -33,14 +33,43 @@
def send_discord_dm(user_id: str, text: str) -> bool:
response = discord_api.create_dm_channel(user_id)
if response.status_code != 200:
eprint(f" [Discord] Failed to create DM channel for {user_id}: {response.text}")
log_event(
"discord_dm_delivery",
"notifications",
level="warning",
platform="Discord",
status="failure",
stage="create_channel",
user_id=user_id,
http_status=response.status_code,
)
return False

dm_channel_id = response.json()["id"]
message_response = discord_api.send_channel_message(dm_channel_id, text)
if message_response.status_code != 200:
eprint(f" [Discord] Failed to send message to {user_id}: {message_response.text}")
log_event(
"discord_dm_delivery",
"notifications",
level="warning",
platform="Discord",
status="failure",
stage="send_message",
user_id=user_id,
channel_id=dm_channel_id,
http_status=message_response.status_code,
)
return False
log_event(
"discord_dm_delivery",
"notifications",
platform="Discord",
status="success",
stage="send_message",
user_id=user_id,
channel_id=dm_channel_id,
http_status=message_response.status_code,
)
return True


Expand All @@ -57,12 +86,35 @@ def send_discord_channel_message(channel_id: str, message: str) -> bool:
try:
response = discord_api.send_channel_message(channel_id, message)
if response.status_code in (200, 201):
eprint(f"[notify_staff] 成功發送通知到 Discord channel {channel_id}")
log_event(
"discord_channel_message",
"notifications",
platform="Discord",
status="success",
channel_id=channel_id,
http_status=response.status_code,
)
return True
eprint(f"[notify_staff] 發送失敗: {response.status_code} - {response.text}")
log_event(
"discord_channel_message",
"notifications",
level="warning",
platform="Discord",
status="failure",
channel_id=channel_id,
http_status=response.status_code,
)
return False
except Exception as e:
eprint(f"[notify_staff] 發送通知時發生錯誤: {e}")
log_event(
"discord_channel_message",
"notifications",
level="error",
platform="Discord",
status="error",
channel_id=channel_id,
error=e,
)
return False


Expand Down
245 changes: 245 additions & 0 deletions src/ian/utils/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (c) 2026 NTU AI Club
#
# This file is part of Ian, an open-source AI agent framework developed
# and maintained by NTU AI Club.
#
# Ian is licensed under the GNU General Public License, either version 3
# of the License, or (at your option) any later version.
#
# Ian is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ian. If not, see <https://www.gnu.org/licenses/>.
#

from __future__ import annotations

import hashlib
import json
import sys
import threading
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import Any, TextIO

from ian.config import TZ_TPE


LOG_LEVELS = frozenset({"debug", "info", "warning", "error", "critical"})
REDACTED = "[REDACTED]"
Identifier = str | int | None

_IDENTIFIER_FIELDS = frozenset(
{
"account_id",
"channel_id",
"recipient_id",
"sender_id",
"session_id",
"user_id",
}
)
_CONTENT_FIELDS = frozenset(
{
"content",
"input",
"message",
"message_content",
"prompt",
"query",
"raw_content",
"request_body",
"response",
"text",
"user_message",
}
)
_RESERVED_FIELDS = frozenset(
{
"timestamp",
"level",
"event",
"component",
"platform",
"status",
"duration_ms",
"error_type",
"correlation_id",
}
)


def _stable_hash(value: Identifier) -> str:
if value is None:
return ""
text = str(value).strip()
if not text:
return ""
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
return f"sha256:{digest}"


def hash_account_id(account_id: Identifier) -> str:
"""Return a stable pseudonym for a platform account identifier."""
return _stable_hash(account_id)


def hash_email(email: str | None) -> str:
"""Return a stable, case-insensitive pseudonym for an email address."""
if email is None:
return ""
return _stable_hash(str(email).strip().lower())


def redact_token(token: str | None) -> str:
"""Remove a token or secret while preserving whether a value was present."""
if token is None:
return ""
return REDACTED if str(token).strip() else ""


def redact_user_content(content: str | None) -> str:
"""Remove raw user content and retain only its character count."""
if content is None:
return ""
text = str(content)
return f"[REDACTED content_length={len(text)}]" if text else ""


def _redaction_kind(field_name: str) -> str | None:
normalized = field_name.lower()
if (
"token" in normalized
or "secret" in normalized
or normalized.endswith("api_key")
or normalized == "authorization"
):
return "token"
if normalized == "email" or normalized.endswith("_email"):
return "email"
if normalized in _IDENTIFIER_FIELDS or normalized.endswith("_account_id"):
return "identifier"
if normalized in _CONTENT_FIELDS or normalized.endswith("_content"):
return "content"
return None


def _as_identifier(value: Any) -> Identifier:
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return value
return str(value)


def _as_text(value: Any) -> str | None:
return None if value is None else str(value)


def _sanitize_value(field_name: str, value: Any) -> Any:
kind = _redaction_kind(field_name)
if kind == "token":
return redact_token(_as_text(value))
if kind == "email":
return hash_email(_as_text(value))
if kind == "identifier":
return hash_account_id(_as_identifier(value))
if kind == "content":
return redact_user_content(_as_text(value))
if isinstance(value, Mapping):
return {str(key): _sanitize_value(str(key), item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_sanitize_value(field_name, item) for item in value]
return value


def sanitize_log_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
"""Apply the default sensitive-field policy to structured log fields."""
return {str(key): _sanitize_value(str(key), value) for key, value in fields.items()}


class StructuredLogger:
"""Emit sanitized JSON Lines to stderr or another local text stream."""

def __init__(
self,
*,
stream: TextIO | None = None,
clock: Callable[[], datetime] | None = None,
) -> None:
self._stream = stream
self._clock = clock or (lambda: datetime.now(TZ_TPE))
self._lock = threading.Lock()

def emit(
self,
event: str,
component: str,
*,
level: str = "info",
platform: str | None = None,
status: str | None = None,
duration_ms: float | int | None = None,
error: BaseException | None = None,
correlation_id: str | None = None,
**fields: Any,
) -> dict[str, Any]:
if level not in LOG_LEVELS:
raise ValueError(f"Unsupported log level: {level}")
if not isinstance(event, str) or not event.strip():
raise ValueError("event and component are required")
if not isinstance(component, str) or not component.strip():
raise ValueError("event and component are required")

conflicting = _RESERVED_FIELDS.intersection(fields)
if conflicting:
names = ", ".join(sorted(conflicting))
raise ValueError(f"Reserved log fields must use named parameters: {names}")

timestamp = self._clock()
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=TZ_TPE)
timestamp = timestamp.astimezone(TZ_TPE)

entry: dict[str, Any] = {
"timestamp": timestamp.isoformat(),
"level": level,
"event": event,
"component": component,
}
optional = {
"platform": platform,
"status": status,
"duration_ms": duration_ms,
"error_type": type(error).__name__ if error is not None else None,
"correlation_id": correlation_id,
}
entry.update({key: value for key, value in optional.items() if value is not None})
entry.update(sanitize_log_fields(fields))

payload = json.dumps(
entry,
ensure_ascii=False,
separators=(",", ":"),
default=str,
allow_nan=False,
)
stream = self._stream or sys.stderr
with self._lock:
stream.write(payload + "\n")
stream.flush()
return entry


_application_logger = StructuredLogger()


def log_event(event: str, component: str, **kwargs: Any) -> dict[str, Any]:
"""Emit an application event through the shared structured logger."""
return _application_logger.emit(event, component, **kwargs)
19 changes: 15 additions & 4 deletions tests/services/test_discord_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
# along with Ian. If not, see <https://www.gnu.org/licenses/>.
#

import json

from ian.services import notifications
from ian.services.agent import logging as agent_logging

Expand Down Expand Up @@ -88,7 +90,7 @@ def fake_post(url, headers, json, timeout):
]


def test_send_discord_dm_uses_shared_client_and_preserves_failure_message(
def test_send_discord_dm_uses_shared_client_and_redacts_failure_details(
monkeypatch, capsys
):
from ian.services import discord_api
Expand All @@ -101,8 +103,13 @@ def fake_create_dm_channel(user_id):

assert notifications.send_discord_dm("user-1", "hello") is False

captured = capsys.readouterr()
assert "Failed to create DM channel for user-1: cannot create" in captured.err
log_entry = json.loads(capsys.readouterr().err)
assert log_entry["event"] == "discord_dm_delivery"
assert log_entry["status"] == "failure"
assert log_entry["stage"] == "create_channel"
assert log_entry["user_id"].startswith("sha256:")
assert "user-1" not in json.dumps(log_entry)
assert "cannot create" not in json.dumps(log_entry)


def test_send_discord_channel_message_uses_shared_client_for_success(monkeypatch, capsys):
Expand All @@ -120,7 +127,11 @@ def fake_send_channel_message(channel_id, message):
assert calls == [("channel-1", "hello")]

captured = capsys.readouterr()
assert "成功發送通知到 Discord channel channel-1" in captured.err
log_entry = json.loads(captured.err)
assert log_entry["event"] == "discord_channel_message"
assert log_entry["status"] == "success"
assert log_entry["channel_id"].startswith("sha256:")
assert "channel-1" not in captured.err


def test_agent_logging_uses_shared_client_for_failure(monkeypatch, capsys):
Expand Down
Loading