feedBack/lib/logging_setup.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

176 lines
6.4 KiB
Python

"""Logging configuration for FeedBack.
Call ``configure_logging()`` once at server startup, before any feedBack
module imports that might emit log records.
Environment variables:
LOG_LEVEL — severity threshold for the ``feedBack.*`` logger tree
(default: INFO). Also accepted: DEBUG, WARNING, ERROR.
LOG_FORMAT — "json" for structured output (Loki, ELK, Promtail);
"text" (default) for human-readable coloured console output.
LOG_FILE — optional path; when set, a RotatingFileHandler is added
alongside the console handler (max 10 MB, 5 backups).
The parent directory is created automatically if it does not
exist. If the file cannot be opened, a warning is printed
and the server continues with console-only logging.
Useful for persistent NAS deployments.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import sys
from pathlib import Path
import structlog
def _add_correlation_id(
logger: object, method_name: str, event_dict: dict
) -> dict:
"""Inject the current request correlation ID into the event dict."""
try:
from asgi_correlation_id import correlation_id
cid = correlation_id.get(None)
if cid:
event_dict["request_id"] = cid
except ImportError:
pass
return event_dict
def configure_logging() -> None:
"""Wire up the feedBack logger hierarchy.
Safe to call multiple times; always reflects the current LOG_LEVEL,
LOG_FORMAT, and LOG_FILE environment variables.
"""
raw_level = os.environ.get("LOG_LEVEL", "INFO").upper()
level = getattr(logging, raw_level, None)
if not isinstance(level, int):
sys.stderr.write(
f"[feedBack] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
" falling back to INFO.\n"
)
level = logging.INFO
raw_fmt = os.environ.get("LOG_FORMAT", "text").lower()
if raw_fmt not in ("json", "text"):
sys.stderr.write(
f"[feedBack] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
" falling back to 'text'.\n"
)
raw_fmt = "text"
fmt = raw_fmt
log_file = os.environ.get("LOG_FILE", "").strip()
# Console renderer: coloured when text mode, JSON otherwise.
console_renderer = (
structlog.processors.JSONRenderer()
if fmt == "json"
else structlog.dev.ConsoleRenderer()
)
# File renderer: always plain (no ANSI escape sequences) so rotated log
# files are human-readable without a terminal. JSON mode reuses the same
# renderer because JSON output is already colour-free.
file_renderer = (
structlog.processors.JSONRenderer()
if fmt == "json"
else structlog.dev.ConsoleRenderer(colors=False)
)
# Applied to all records — both structlog-native and stdlib (foreign) calls.
# Stdlib logging handles %-style format strings itself, so no
# PositionalArgumentsFormatter is needed here.
pre_chain: list = [
structlog.contextvars.merge_contextvars,
_add_correlation_id,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
structlog.configure(
processors=pre_chain + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
# Keep False so that every reconfigure() call takes effect immediately
# for any code that holds a structlog.get_logger() proxy. The small
# per-call overhead is acceptable given that logging is not on the hot
# path.
cache_logger_on_first_use=False,
)
def _make_formatter(renderer: object) -> structlog.stdlib.ProcessorFormatter:
return structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
# Format exc_info tuples to strings before the renderer so that
# JSONRenderer never encounters a non-serializable traceback object.
structlog.processors.ExceptionRenderer(),
renderer,
],
foreign_pre_chain=pre_chain,
)
console_formatter = _make_formatter(console_renderer)
console = logging.StreamHandler(sys.stdout)
console.setFormatter(console_formatter)
handlers: list[logging.Handler] = [console]
if log_file:
file_formatter = _make_formatter(file_renderer)
try:
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
fh = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
fh.setFormatter(file_formatter)
handlers.append(fh)
except OSError as exc:
sys.stderr.write(
f"[feedBack] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
" — continuing with console-only logging.\n"
)
_uvicorn_names = ("uvicorn", "uvicorn.error", "uvicorn.access")
all_loggers = [logging.getLogger("feedBack")] + [
logging.getLogger(n) for n in _uvicorn_names
]
# Collect all unique old handlers across every logger *before* any close so
# that a shared handler (feedBack and uvicorn* were intentionally given the
# same objects) isn't closed while still attached to another logger tree.
old_handlers: set[logging.Handler] = set()
for lg in all_loggers:
old_handlers.update(lg.handlers)
# Detach first, then close each unique handler exactly once.
for lg in all_loggers:
for h in list(lg.handlers):
lg.removeHandler(h)
for h in old_handlers:
h.close()
# Install fresh handlers on the feedBack root.
root = logging.getLogger("feedBack")
for h in handlers:
root.addHandler(h)
root.setLevel(level)
root.propagate = False
# Route uvicorn output through the same pipeline so everything is uniform.
for name in _uvicorn_names:
lg = logging.getLogger(name)
lg.handlers = list(handlers)
lg.propagate = False
lg.setLevel(level)