jaylog 0.2.8__tar.gz → 0.3.0a2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jaylog-0.3.0a2/PKG-INFO +51 -0
- jaylog-0.3.0a2/README.md +32 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/pyproject.toml +19 -1
- jaylog-0.3.0a2/src/jaylog/__init__.py +37 -0
- jaylog-0.3.0a2/src/jaylog/_compat.py +29 -0
- jaylog-0.3.0a2/src/jaylog/_version.py +19 -0
- jaylog-0.3.0a2/src/jaylog/context.py +93 -0
- jaylog-0.3.0a2/src/jaylog/endpoints.py +26 -0
- jaylog-0.3.0a2/src/jaylog/formatters.py +33 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/console_handler.py +8 -6
- jaylog-0.3.0a2/src/jaylog/handlers/http_handler.py +102 -0
- jaylog-0.3.0a2/src/jaylog/host/__init__.py +6 -0
- jaylog-0.3.0a2/src/jaylog/host/_safe.py +56 -0
- jaylog-0.3.0a2/src/jaylog/host/collectors.py +131 -0
- jaylog-0.3.0a2/src/jaylog/host/detect_execution.py +202 -0
- jaylog-0.3.0a2/src/jaylog/host/detect_git.py +209 -0
- jaylog-0.3.0a2/src/jaylog/host/detect_python.py +28 -0
- jaylog-0.3.0a2/src/jaylog/host/detect_venv.py +79 -0
- jaylog-0.3.0a2/src/jaylog/host/identity.py +53 -0
- jaylog-0.3.0a2/src/jaylog/host/models.py +92 -0
- jaylog-0.3.0a2/src/jaylog/host/payload.py +29 -0
- jaylog-0.3.0a2/src/jaylog/host/reporter.py +295 -0
- jaylog-0.3.0a2/src/jaylog/host/win32.py +148 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/logger.py +128 -18
- jaylog-0.3.0a2/src/jaylog/runtime.py +21 -0
- jaylog-0.3.0a2/src/jaylog/screenshot.py +102 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/settings.py +70 -19
- jaylog-0.2.8/PKG-INFO +0 -287
- jaylog-0.2.8/README.md +0 -268
- jaylog-0.2.8/src/jaylog/__init__.py +0 -5
- jaylog-0.2.8/src/jaylog/formatters.py +0 -118
- jaylog-0.2.8/src/jaylog/handlers/http_handler.py +0 -78
- jaylog-0.2.8/src/jaylog/models.py +0 -17
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/colors.py +0 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/filters.py +0 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/__init__.py +0 -0
- {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/file_handler.py +0 -0
jaylog-0.3.0a2/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jaylog
|
|
3
|
+
Version: 0.3.0a2
|
|
4
|
+
Summary: Customized Python logging library with file rotation and HTTP forwarding
|
|
5
|
+
Author: Gpocas
|
|
6
|
+
Author-email: Gpocas <gpocas01@gmail.com>
|
|
7
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
8
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
9
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Requires-Dist: pydantic>=2.0,<3.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.0,<3.0
|
|
13
|
+
Requires-Dist: requests>=2.32,<3.0
|
|
14
|
+
Requires-Dist: pillow>=11.0,<13.0
|
|
15
|
+
Requires-Python: >=3.10, <3.14
|
|
16
|
+
Project-URL: repository, https://github.com/Gpocas/jaylog
|
|
17
|
+
Project-URL: Bug Tracker, https://github.com/Gpocas/jaylog/issues
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# jaylog
|
|
21
|
+
|
|
22
|
+
Biblioteca de logging para Python com rotação de arquivos, saída colorida no console, envio HTTP para um endpoint remoto e registro automático do ambiente de execução (host reporting).
|
|
23
|
+
|
|
24
|
+
📖 **Documentação completa:** https://gpocas.github.io/jaylog-book
|
|
25
|
+
|
|
26
|
+
## Instalação
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -U --no-cache-dir jaylog
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Uso rápido
|
|
33
|
+
|
|
34
|
+
__*.env.logging*__
|
|
35
|
+
```env
|
|
36
|
+
JAYLOG_APP_NAME=meu-bot
|
|
37
|
+
JAYLOG_LOG_DIR=C:\logs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
__*main.py*__
|
|
41
|
+
```python
|
|
42
|
+
from jaylog import JaylogSettings, configure, get_logger
|
|
43
|
+
|
|
44
|
+
configure(JaylogSettings())
|
|
45
|
+
|
|
46
|
+
logger = get_logger()
|
|
47
|
+
|
|
48
|
+
logger.info("Olá, jaylog!")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Para variáveis de ambiente, cenários de uso (console-only, múltiplos loggers, cores), preparação para produção (secrets, host reporting) e referência da API, veja a [documentação guiada](https://gpocas.github.io/jaylog-book).
|
jaylog-0.3.0a2/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# jaylog
|
|
2
|
+
|
|
3
|
+
Biblioteca de logging para Python com rotação de arquivos, saída colorida no console, envio HTTP para um endpoint remoto e registro automático do ambiente de execução (host reporting).
|
|
4
|
+
|
|
5
|
+
📖 **Documentação completa:** https://gpocas.github.io/jaylog-book
|
|
6
|
+
|
|
7
|
+
## Instalação
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -U --no-cache-dir jaylog
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Uso rápido
|
|
14
|
+
|
|
15
|
+
__*.env.logging*__
|
|
16
|
+
```env
|
|
17
|
+
JAYLOG_APP_NAME=meu-bot
|
|
18
|
+
JAYLOG_LOG_DIR=C:\logs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
__*main.py*__
|
|
22
|
+
```python
|
|
23
|
+
from jaylog import JaylogSettings, configure, get_logger
|
|
24
|
+
|
|
25
|
+
configure(JaylogSettings())
|
|
26
|
+
|
|
27
|
+
logger = get_logger()
|
|
28
|
+
|
|
29
|
+
logger.info("Olá, jaylog!")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Para variáveis de ambiente, cenários de uso (console-only, múltiplos loggers, cores), preparação para produção (secrets, host reporting) e referência da API, veja a [documentação guiada](https://gpocas.github.io/jaylog-book).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jaylog"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0a2"
|
|
4
4
|
description = "Customized Python logging library with file rotation and HTTP forwarding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -26,6 +26,24 @@ dependencies = [
|
|
|
26
26
|
repository = "https://github.com/Gpocas/jaylog"
|
|
27
27
|
"Bug Tracker" = "https://github.com/Gpocas/jaylog/issues"
|
|
28
28
|
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8,<9",
|
|
32
|
+
"ruff>=0.6",
|
|
33
|
+
]
|
|
34
|
+
|
|
29
35
|
[build-system]
|
|
30
36
|
requires = ["uv_build>=0.10.8,<0.11.0"]
|
|
31
37
|
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
addopts = "-q"
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 100
|
|
45
|
+
src = ["src", "tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
49
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from jaylog._version import PROTOCOL_VERSION, __version__
|
|
2
|
+
from jaylog.logger import configure, get_logger, shutdown
|
|
3
|
+
from jaylog.settings import JaylogSettings
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"configure",
|
|
7
|
+
"get_logger",
|
|
8
|
+
"shutdown",
|
|
9
|
+
"JaylogSettings",
|
|
10
|
+
"__version__",
|
|
11
|
+
"PROTOCOL_VERSION",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
_DEPRECATED = {"LogEntry"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str):
|
|
18
|
+
# `jaylog.models.LogEntry` era código morto — nunca foi construído, e o tipo
|
|
19
|
+
# declarado nem batia com o que o handler HTTP envia. Fica acessível por um
|
|
20
|
+
# release para não quebrar `from jaylog import LogEntry` em código de bot.
|
|
21
|
+
if name in _DEPRECATED:
|
|
22
|
+
import warnings
|
|
23
|
+
|
|
24
|
+
warnings.warn(
|
|
25
|
+
f"jaylog.{name} está depreciado e será removido na 0.4.0; "
|
|
26
|
+
"o modelo nunca foi usado pela biblioteca.",
|
|
27
|
+
DeprecationWarning,
|
|
28
|
+
stacklevel=2,
|
|
29
|
+
)
|
|
30
|
+
from jaylog import _compat
|
|
31
|
+
|
|
32
|
+
return getattr(_compat, name)
|
|
33
|
+
raise AttributeError(f"module 'jaylog' has no attribute '{name}'")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __dir__() -> list[str]:
|
|
37
|
+
return sorted([*__all__, *_DEPRECATED])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shim de depreciação de ``jaylog.models``.
|
|
3
|
+
|
|
4
|
+
``LogEntry`` era código morto: nunca foi construído em lugar nenhum, e o tipo
|
|
5
|
+
declarado (``log_img: Optional[str]``) nem batia com os ``bytes`` que o handler
|
|
6
|
+
HTTP envia. Fica acessível por mais um release via ``jaylog.LogEntry``, que
|
|
7
|
+
emite ``DeprecationWarning``. Remover na 0.4.0.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LogEntry(BaseModel):
|
|
16
|
+
log_timestamp: datetime
|
|
17
|
+
log_level: str
|
|
18
|
+
is_exception: bool
|
|
19
|
+
log_message: str
|
|
20
|
+
service: str
|
|
21
|
+
username: str
|
|
22
|
+
hostname: str
|
|
23
|
+
ipv4: str
|
|
24
|
+
service_path: str
|
|
25
|
+
line_number: int
|
|
26
|
+
log_img: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["LogEntry"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Versão do pacote e versão do protocolo de ingestão.
|
|
3
|
+
|
|
4
|
+
Lookup único de ``importlib.metadata``: o valor era recalculado em
|
|
5
|
+
``handlers/http_handler.py`` e voltaria a ser recalculado no reporter de host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
#: Versão do contrato cliente <-> backend. Vai no header ``x-jaylog-protocol``
|
|
11
|
+
#: e no payload de host. Mede o rollout; não dirige lógica no backend.
|
|
12
|
+
PROTOCOL_VERSION = 2
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("jaylog")
|
|
16
|
+
except PackageNotFoundError: # rodando do fonte, sem instalação
|
|
17
|
+
__version__ = "unknown"
|
|
18
|
+
|
|
19
|
+
__all__ = ["__version__", "PROTOCOL_VERSION"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enriquecimento do ``LogRecord``, memoizado **no próprio record**.
|
|
3
|
+
|
|
4
|
+
O problema que isto resolve: ``build_log_entry_dict()`` é chamado uma vez por
|
|
5
|
+
handler downstream (arquivo, console, HTTP) e mais uma vez dentro do
|
|
6
|
+
``PlainTextFormatter``. Com screenshot ligado, o mesmo erro capturava a tela até
|
|
7
|
+
três vezes — três ``ImageGrab.grab()`` sequenciais, cada um na casa das centenas
|
|
8
|
+
de milissegundos, para produzir três imagens quase idênticas das quais duas eram
|
|
9
|
+
descartadas. Memoizar no record resolve porque o record é exatamente o escopo do
|
|
10
|
+
"uma vez por evento de log".
|
|
11
|
+
|
|
12
|
+
O screenshot saiu do caminho compartilhado: ``record_screenshot()`` é chamado só
|
|
13
|
+
pelo ``JaylogHttpHandler``, que é o único com para onde mandá-lo.
|
|
14
|
+
|
|
15
|
+
.. warning::
|
|
16
|
+
Este enriquecimento tem que continuar **downstream da fila**.
|
|
17
|
+
``QueueHandler.prepare()`` já faz ``record.msg = self.format(record)`` e zera
|
|
18
|
+
``exc_info``; por isso, aqui embaixo, ``record.exc_info`` é sempre ``None`` e
|
|
19
|
+
o traceback sobrevive apenas por já estar embutido em ``getMessage()``. Mover
|
|
20
|
+
o enriquecimento para antes da fila (um ``Filter`` no ``QueueHandler``, por
|
|
21
|
+
exemplo) duplicaria o traceback — e ainda rodaria na thread do chamador.
|
|
22
|
+
Há um teste de regressão travando isso.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import traceback
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
from jaylog.host import identity
|
|
30
|
+
from jaylog.screenshot import capture_screenshot
|
|
31
|
+
|
|
32
|
+
_ENTRY_ATTR = "_jaylog_entry"
|
|
33
|
+
_SCREENSHOT_ATTR = "_jaylog_screenshot"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_log_entry_dict(record: logging.LogRecord) -> dict:
|
|
37
|
+
"""
|
|
38
|
+
Campos do log, sem ``log_img``.
|
|
39
|
+
|
|
40
|
+
``hostname``/``username``/``ipv4`` continuam no corpo de todo log — a
|
|
41
|
+
tabela ``logs`` os mantém desnormalizados de forma permanente, então não há
|
|
42
|
+
o que migrar.
|
|
43
|
+
"""
|
|
44
|
+
is_exception = getattr(record, "is_exception", False)
|
|
45
|
+
|
|
46
|
+
log_message = record.getMessage()
|
|
47
|
+
if is_exception and record.exc_info:
|
|
48
|
+
tb = "".join(traceback.format_exception(*record.exc_info)).strip()
|
|
49
|
+
log_message = f"{log_message}\n{tb}"
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
"log_timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
53
|
+
"log_level": "EXCEPTION" if is_exception else record.levelname,
|
|
54
|
+
"is_exception": is_exception,
|
|
55
|
+
"log_message": log_message,
|
|
56
|
+
"service": record.name,
|
|
57
|
+
"username": identity.username(),
|
|
58
|
+
"hostname": identity.hostname(),
|
|
59
|
+
"ipv4": identity.ipv4(),
|
|
60
|
+
"service_path": record.pathname,
|
|
61
|
+
"line_number": record.lineno,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def record_entry(record: logging.LogRecord) -> dict:
|
|
66
|
+
"""``build_log_entry_dict`` com o resultado guardado no record."""
|
|
67
|
+
entry = getattr(record, _ENTRY_ATTR, None)
|
|
68
|
+
if entry is None:
|
|
69
|
+
entry = build_log_entry_dict(record)
|
|
70
|
+
setattr(record, _ENTRY_ATTR, entry)
|
|
71
|
+
return entry
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def wants_screenshot(record: logging.LogRecord) -> bool:
|
|
75
|
+
return record.levelno >= logging.ERROR or bool(getattr(record, "is_exception", False))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def record_screenshot(record: logging.LogRecord) -> bytes | None:
|
|
79
|
+
"""
|
|
80
|
+
Screenshot do evento, capturado no máximo uma vez por record.
|
|
81
|
+
|
|
82
|
+
O sentinela guardado no record distingue "ainda não tentamos" de "tentamos e
|
|
83
|
+
não veio nada" — sem ele, um ``None`` legítimo faria a próxima chamada
|
|
84
|
+
capturar de novo.
|
|
85
|
+
"""
|
|
86
|
+
if _SCREENSHOT_ATTR in record.__dict__:
|
|
87
|
+
return record.__dict__[_SCREENSHOT_ATTR]
|
|
88
|
+
image = capture_screenshot() if wants_screenshot(record) else None
|
|
89
|
+
record.__dict__[_SCREENSHOT_ATTR] = image
|
|
90
|
+
return image
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["build_log_entry_dict", "record_entry", "record_screenshot", "wants_screenshot"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Derivação da URL do endpoint de host a partir da URL do endpoint de log.
|
|
3
|
+
|
|
4
|
+
Deploys existentes já configuram ``JAYLOG_LOG_HTTP_ENDPOINT``; trocar o último
|
|
5
|
+
segmento do path (``/logs/add`` -> ``/logs/host``) evita exigir uma variável
|
|
6
|
+
nova de todo mundo. Quem precisar de outro caminho usa
|
|
7
|
+
``JAYLOG_HOST_HTTP_ENDPOINT``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_host_endpoint(log_endpoint: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
``https://api/logs/add`` -> ``https://api/logs/host``.
|
|
16
|
+
|
|
17
|
+
Query string e fragmento são preservados; barra final é ignorada. Uma URL
|
|
18
|
+
sem path (``https://api``) vira ``https://api/host``.
|
|
19
|
+
"""
|
|
20
|
+
parts = urlsplit(log_endpoint)
|
|
21
|
+
head, sep, _last = parts.path.rstrip("/").rpartition("/")
|
|
22
|
+
path = f"{head}/host" if sep else "/host"
|
|
23
|
+
return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["derive_host_endpoint"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formatação legível para arquivo.
|
|
3
|
+
|
|
4
|
+
O módulo encolheu: identidade da máquina foi para ``host/identity.py`` (lazy),
|
|
5
|
+
screenshot para ``screenshot.py`` e a montagem do dicionário de log para
|
|
6
|
+
``context.py``. ``build_log_entry_dict`` continua reexportado aqui por compat —
|
|
7
|
+
remover na 0.4.0.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from jaylog.context import build_log_entry_dict, record_entry
|
|
14
|
+
from jaylog.screenshot import configure_screenshot
|
|
15
|
+
|
|
16
|
+
__all__ = ["PlainTextFormatter", "build_log_entry_dict", "configure_screenshot"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlainTextFormatter(logging.Formatter):
|
|
20
|
+
"""Human-readable single-line formatter for .log files."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, show_service: bool = True) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.show_service = show_service
|
|
25
|
+
|
|
26
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
27
|
+
entry = record_entry(record)
|
|
28
|
+
log_timestamp = (
|
|
29
|
+
datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
|
|
30
|
+
)
|
|
31
|
+
log_level = f"[{entry['log_level']}]"
|
|
32
|
+
service_segment = f"[{entry['service']}] | " if self.show_service else ""
|
|
33
|
+
return f"{log_timestamp} {log_level.ljust(11)} | {service_segment}{entry['log_message']}"
|
|
@@ -4,7 +4,7 @@ from datetime import datetime
|
|
|
4
4
|
from typing import IO
|
|
5
5
|
|
|
6
6
|
from jaylog.colors import BLUE, GREEN, PURPLE, RED, RESET, YELLOW, supports_color
|
|
7
|
-
from jaylog.
|
|
7
|
+
from jaylog.context import record_entry
|
|
8
8
|
|
|
9
9
|
_LEVEL_COLORS = {
|
|
10
10
|
"DEBUG": PURPLE,
|
|
@@ -37,12 +37,14 @@ class ConsoleFormatter(logging.Formatter):
|
|
|
37
37
|
return f"{color}{text}{RESET}"
|
|
38
38
|
|
|
39
39
|
def format(self, record: logging.LogRecord) -> str:
|
|
40
|
-
entry =
|
|
41
|
-
log_timestamp =
|
|
42
|
-
|
|
40
|
+
entry = record_entry(record)
|
|
41
|
+
log_timestamp = (
|
|
42
|
+
datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
|
|
43
|
+
)
|
|
44
|
+
log_level = f"[{entry['log_level']}]"
|
|
43
45
|
colored_timestamp = self._paint(log_timestamp, GREEN)
|
|
44
|
-
colored_level = self._paint(log_level.ljust(11), _level_color(entry[
|
|
45
|
-
service_segment = f
|
|
46
|
+
colored_level = self._paint(log_level.ljust(11), _level_color(entry["log_level"]))
|
|
47
|
+
service_segment = f"[{entry['service']}] | " if self.show_service else ""
|
|
46
48
|
return f"{colored_timestamp} {colored_level} | {service_segment}{entry['log_message']}"
|
|
47
49
|
|
|
48
50
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
import urllib3
|
|
8
|
+
|
|
9
|
+
from jaylog._version import PROTOCOL_VERSION, __version__
|
|
10
|
+
from jaylog.context import record_entry, record_screenshot
|
|
11
|
+
from jaylog.host import reporter
|
|
12
|
+
from jaylog.runtime import RUN_ID
|
|
13
|
+
|
|
14
|
+
#: O backend sinaliza por header que não conhece este `run_id` — normalmente
|
|
15
|
+
#: porque o log correu na frente do POST de host. Ver `reporter.request_resend`.
|
|
16
|
+
HOST_REQUIRED_HEADER = "x-jaylog-host-required"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_multipart(fields: dict) -> dict:
|
|
20
|
+
result = {}
|
|
21
|
+
for key, value in fields.items():
|
|
22
|
+
if value is None:
|
|
23
|
+
continue
|
|
24
|
+
if isinstance(value, bytes):
|
|
25
|
+
result[key] = ("screenshot.jpg", value, "image/jpeg")
|
|
26
|
+
elif isinstance(value, bool):
|
|
27
|
+
result[key] = (None, "true" if value else "false")
|
|
28
|
+
elif not isinstance(value, str):
|
|
29
|
+
result[key] = (None, json.dumps(value))
|
|
30
|
+
else:
|
|
31
|
+
result[key] = (None, value)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JaylogHttpHandler(logging.Handler):
|
|
36
|
+
"""
|
|
37
|
+
HTTP handler that POSTs log records as multipart/form-data to a remote endpoint.
|
|
38
|
+
|
|
39
|
+
Non-blocking behaviour is guaranteed by the QueueListener that drives this
|
|
40
|
+
handler — emit() runs in the listener's background thread.
|
|
41
|
+
|
|
42
|
+
O **corpo** do POST é byte-a-byte o mesmo da 0.2.x: ``logs`` mantém
|
|
43
|
+
``hostname``/``username``/``ipv4`` desnormalizados de forma permanente, então
|
|
44
|
+
não há campo a remover. O que muda são dois headers novos
|
|
45
|
+
(``x-jaylog-protocol`` e ``x-jaylog-run-id``); um backend antigo simplesmente
|
|
46
|
+
os ignora.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
endpoint: str,
|
|
52
|
+
api_key: str,
|
|
53
|
+
timeout: float = 5.0,
|
|
54
|
+
proxy: str | None = None,
|
|
55
|
+
verify: bool | str = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.endpoint = endpoint
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.verify = verify
|
|
61
|
+
self.proxy = {"http": proxy, "https": proxy} if proxy else None
|
|
62
|
+
self._session = requests.Session()
|
|
63
|
+
self._session.headers["x-api-key"] = api_key
|
|
64
|
+
self._session.headers["x-jaylog-version"] = __version__
|
|
65
|
+
self._session.headers["x-jaylog-protocol"] = str(PROTOCOL_VERSION)
|
|
66
|
+
self._session.headers["x-jaylog-run-id"] = RUN_ID
|
|
67
|
+
|
|
68
|
+
def mapLogRecord(self, record: logging.LogRecord) -> dict:
|
|
69
|
+
# Único ponto do programa que pede screenshot: é o único com para onde
|
|
70
|
+
# mandá-la. Console e arquivo não capturam mais nada.
|
|
71
|
+
entry = dict(record_entry(record))
|
|
72
|
+
entry["log_img"] = record_screenshot(record)
|
|
73
|
+
return entry
|
|
74
|
+
|
|
75
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
76
|
+
try:
|
|
77
|
+
if self.proxy:
|
|
78
|
+
self._session.proxies.update(self.proxy)
|
|
79
|
+
|
|
80
|
+
with warnings.catch_warnings():
|
|
81
|
+
warnings.simplefilter("ignore", urllib3.exceptions.InsecureRequestWarning)
|
|
82
|
+
response = self._session.post(
|
|
83
|
+
self.endpoint,
|
|
84
|
+
files=_to_multipart(self.mapLogRecord(record)),
|
|
85
|
+
timeout=self.timeout,
|
|
86
|
+
verify=self.verify,
|
|
87
|
+
)
|
|
88
|
+
if os.getenv("JAYLOG_HTTP_DEBUG") == "1":
|
|
89
|
+
print(response.status_code)
|
|
90
|
+
print(response.content)
|
|
91
|
+
|
|
92
|
+
if response.headers.get(HOST_REQUIRED_HEADER) == "1":
|
|
93
|
+
# O log foi aceito (202) — não há nada a reenviar aqui. O que
|
|
94
|
+
# falta é o registro de ambiente, que o reporter reenvia (com
|
|
95
|
+
# debounce, porque o header vem em toda linha afetada).
|
|
96
|
+
reporter.request_resend(record.name)
|
|
97
|
+
except Exception:
|
|
98
|
+
# `handleError` respeita `logging.raiseExceptions`: barulho em dev,
|
|
99
|
+
# silêncio em produção. O `except: pass` anterior era um buraco
|
|
100
|
+
# negro — era por causa dele que ninguém descobria que o caminho
|
|
101
|
+
# HTTP estava falhando.
|
|
102
|
+
self.handleError(record)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Intencionalmente vazio.
|
|
2
|
+
#
|
|
3
|
+
# `settings.py` precisa de `host.identity` sem arrastar `requests` (via
|
|
4
|
+
# `reporter`) nem `subprocess` (via `detect_git`). Re-exportar os submódulos
|
|
5
|
+
# aqui recriaria esse acoplamento no import. Importe sempre o submódulo
|
|
6
|
+
# explícito: `from jaylog.host import identity`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``@safe``: nenhum coletor de host pode derrubar a aplicação.
|
|
3
|
+
|
|
4
|
+
A coleta é observabilidade — se a leitura do Toolhelp falhar, se o antivírus
|
|
5
|
+
bloquear o ``git``, se o ``pyvenv.cfg`` estiver corrompido, o campo vira
|
|
6
|
+
``None`` e o resto do payload segue. O contrato é que um coletor **nunca**
|
|
7
|
+
levanta.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import functools
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any, TypeVar
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _debug_enabled() -> bool:
|
|
22
|
+
return os.environ.get("JAYLOG_DEBUG") == "1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe(default: Any = None) -> Callable[[F], F]:
|
|
26
|
+
"""
|
|
27
|
+
Devolve ``default`` quando a função levanta.
|
|
28
|
+
|
|
29
|
+
Captura ``Exception``, **não** ``BaseException``: engolir ``KeyboardInterrupt``
|
|
30
|
+
numa thread de coleta esconderia o Ctrl-C do usuário, e ``SystemExit`` tem
|
|
31
|
+
que continuar subindo.
|
|
32
|
+
|
|
33
|
+
O traceback só é impresso em stderr com ``JAYLOG_DEBUG=1`` — em produção a
|
|
34
|
+
falha é silenciosa por desenho, já que o payload carrega a evidência
|
|
35
|
+
(o campo fica ``None``).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def decorator(func: F) -> F:
|
|
39
|
+
@functools.wraps(func)
|
|
40
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
41
|
+
try:
|
|
42
|
+
return func(*args, **kwargs)
|
|
43
|
+
except Exception:
|
|
44
|
+
if _debug_enabled():
|
|
45
|
+
print(f"[jaylog] host: falha em {func.__name__}", file=sys.stderr)
|
|
46
|
+
traceback.print_exc(file=sys.stderr)
|
|
47
|
+
# copy para que um default mutável (`{}`, `[]`) não seja
|
|
48
|
+
# compartilhado entre chamadas.
|
|
49
|
+
return copy.copy(default)
|
|
50
|
+
|
|
51
|
+
return wrapper # type: ignore[return-value]
|
|
52
|
+
|
|
53
|
+
return decorator
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["safe"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``collect_host_info()``: junta todos os detectores num ``HostInfo``.
|
|
3
|
+
|
|
4
|
+
Memoizado por processo e thread-safe. O custo da coleta (~100-500 ms: um
|
|
5
|
+
snapshot Toolhelp mais até seis subprocessos ``git``) é pago **uma vez**, na
|
|
6
|
+
thread de startup — nunca na thread do chamador de ``configure()``.
|
|
7
|
+
|
|
8
|
+
Os parâmetros de git vêm da primeira chamada: dois ``app_name`` no mesmo
|
|
9
|
+
processo compartilham o mesmo ambiente, então coletar duas vezes só gastaria
|
|
10
|
+
tempo para produzir o mesmo retrato.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import threading
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from jaylog.host import detect_execution, detect_git, detect_python, detect_venv, identity
|
|
20
|
+
from jaylog.host._safe import safe
|
|
21
|
+
from jaylog.host.models import HostInfo
|
|
22
|
+
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
_cached: HostInfo | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@safe(default=(None, None, None, None))
|
|
28
|
+
def _os_info() -> tuple[str | None, str | None, str | None, str | None]:
|
|
29
|
+
uname = platform.uname()
|
|
30
|
+
return (
|
|
31
|
+
uname.system or None,
|
|
32
|
+
uname.release or None,
|
|
33
|
+
uname.version or None,
|
|
34
|
+
uname.machine or None,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@safe(default=None)
|
|
39
|
+
def _cwd() -> str | None:
|
|
40
|
+
return os.getcwd()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@safe(default=None)
|
|
44
|
+
def _collect(
|
|
45
|
+
git_enabled: bool,
|
|
46
|
+
git_dir: Path | str | None,
|
|
47
|
+
git_timeout: float,
|
|
48
|
+
git_dirty_enabled: bool,
|
|
49
|
+
git_remote_enabled: bool,
|
|
50
|
+
) -> HostInfo:
|
|
51
|
+
os_system, os_release, os_version, machine = _os_info()
|
|
52
|
+
execution = detect_execution.detect_execution()
|
|
53
|
+
python = detect_python.detect_python()
|
|
54
|
+
venv = detect_venv.detect_venv()
|
|
55
|
+
git = detect_git.detect_git(
|
|
56
|
+
enabled=git_enabled,
|
|
57
|
+
git_dir=git_dir,
|
|
58
|
+
timeout=git_timeout,
|
|
59
|
+
dirty_enabled=git_dirty_enabled,
|
|
60
|
+
remote_enabled=git_remote_enabled,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return HostInfo(
|
|
64
|
+
collected_at=datetime.now(timezone.utc).isoformat(),
|
|
65
|
+
hostname=identity.hostname(),
|
|
66
|
+
username=identity.username(),
|
|
67
|
+
ipv4=identity.ipv4(),
|
|
68
|
+
os_system=os_system,
|
|
69
|
+
os_release=os_release,
|
|
70
|
+
os_version=os_version,
|
|
71
|
+
machine=machine,
|
|
72
|
+
execution_mode=execution.mode,
|
|
73
|
+
execution_detail=execution.detail,
|
|
74
|
+
session_id=execution.session_id,
|
|
75
|
+
process_id=execution.process_id,
|
|
76
|
+
parent_process_name=execution.parent_process_name,
|
|
77
|
+
python_version=python.version,
|
|
78
|
+
python_implementation=python.implementation,
|
|
79
|
+
python_executable=python.executable,
|
|
80
|
+
python_frozen=python.frozen,
|
|
81
|
+
venv_active=venv.active,
|
|
82
|
+
venv_kind=venv.kind,
|
|
83
|
+
venv_path=venv.path,
|
|
84
|
+
git_available=git.available,
|
|
85
|
+
git_version=git.version,
|
|
86
|
+
git_repo=git.repo,
|
|
87
|
+
git_root=git.root,
|
|
88
|
+
git_branch=git.branch,
|
|
89
|
+
git_commit=git.commit,
|
|
90
|
+
git_commit_short=git.commit_short,
|
|
91
|
+
git_commit_msg=git.commit_msg,
|
|
92
|
+
git_dirty=git.dirty,
|
|
93
|
+
git_remote_url=git.remote_url,
|
|
94
|
+
cwd=_cwd(),
|
|
95
|
+
entrypoint=detect_git.entrypoint_dir(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect_host_info(
|
|
100
|
+
*,
|
|
101
|
+
git_enabled: bool = True,
|
|
102
|
+
git_dir: Path | str | None = None,
|
|
103
|
+
git_timeout: float = 3.0,
|
|
104
|
+
git_dirty_enabled: bool = True,
|
|
105
|
+
git_remote_enabled: bool = True,
|
|
106
|
+
) -> HostInfo:
|
|
107
|
+
"""
|
|
108
|
+
Retrato do ambiente, coletado uma vez por processo.
|
|
109
|
+
|
|
110
|
+
Nunca levanta: se o ``@safe`` de backstop devolver ``None``, cai para
|
|
111
|
+
``HostInfo.minimal()``, que ainda carrega ``run_id`` e a identidade da
|
|
112
|
+
máquina.
|
|
113
|
+
"""
|
|
114
|
+
global _cached
|
|
115
|
+
with _lock:
|
|
116
|
+
if _cached is None:
|
|
117
|
+
_cached = (
|
|
118
|
+
_collect(git_enabled, git_dir, git_timeout, git_dirty_enabled, git_remote_enabled)
|
|
119
|
+
or HostInfo.minimal()
|
|
120
|
+
)
|
|
121
|
+
return _cached
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def reset_cache() -> None:
|
|
125
|
+
"""Limpa a memoização. Existe para os testes; não use em produção."""
|
|
126
|
+
global _cached
|
|
127
|
+
with _lock:
|
|
128
|
+
_cached = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["collect_host_info", "reset_cache"]
|