jaylog 0.2.8__tar.gz → 0.3.0a0__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.
Files changed (35) hide show
  1. {jaylog-0.2.8 → jaylog-0.3.0a0}/PKG-INFO +46 -13
  2. {jaylog-0.2.8 → jaylog-0.3.0a0}/README.md +45 -12
  3. {jaylog-0.2.8 → jaylog-0.3.0a0}/pyproject.toml +19 -1
  4. jaylog-0.3.0a0/src/jaylog/__init__.py +37 -0
  5. jaylog-0.3.0a0/src/jaylog/_compat.py +29 -0
  6. jaylog-0.3.0a0/src/jaylog/_version.py +19 -0
  7. jaylog-0.3.0a0/src/jaylog/context.py +93 -0
  8. jaylog-0.3.0a0/src/jaylog/endpoints.py +26 -0
  9. jaylog-0.3.0a0/src/jaylog/formatters.py +33 -0
  10. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/handlers/console_handler.py +8 -6
  11. jaylog-0.3.0a0/src/jaylog/handlers/http_handler.py +102 -0
  12. jaylog-0.3.0a0/src/jaylog/host/__init__.py +6 -0
  13. jaylog-0.3.0a0/src/jaylog/host/_safe.py +56 -0
  14. jaylog-0.3.0a0/src/jaylog/host/collectors.py +130 -0
  15. jaylog-0.3.0a0/src/jaylog/host/detect_execution.py +199 -0
  16. jaylog-0.3.0a0/src/jaylog/host/detect_git.py +206 -0
  17. jaylog-0.3.0a0/src/jaylog/host/detect_python.py +28 -0
  18. jaylog-0.3.0a0/src/jaylog/host/detect_venv.py +79 -0
  19. jaylog-0.3.0a0/src/jaylog/host/identity.py +53 -0
  20. jaylog-0.3.0a0/src/jaylog/host/models.py +91 -0
  21. jaylog-0.3.0a0/src/jaylog/host/payload.py +29 -0
  22. jaylog-0.3.0a0/src/jaylog/host/reporter.py +295 -0
  23. jaylog-0.3.0a0/src/jaylog/host/win32.py +148 -0
  24. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/logger.py +128 -18
  25. jaylog-0.3.0a0/src/jaylog/runtime.py +21 -0
  26. jaylog-0.3.0a0/src/jaylog/screenshot.py +102 -0
  27. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/settings.py +70 -19
  28. jaylog-0.2.8/src/jaylog/__init__.py +0 -5
  29. jaylog-0.2.8/src/jaylog/formatters.py +0 -118
  30. jaylog-0.2.8/src/jaylog/handlers/http_handler.py +0 -78
  31. jaylog-0.2.8/src/jaylog/models.py +0 -17
  32. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/colors.py +0 -0
  33. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/filters.py +0 -0
  34. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/handlers/__init__.py +0 -0
  35. {jaylog-0.2.8 → jaylog-0.3.0a0}/src/jaylog/handlers/file_handler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: jaylog
3
- Version: 0.2.8
3
+ Version: 0.3.0a0
4
4
  Summary: Customized Python logging library with file rotation and HTTP forwarding
5
5
  Author: Gpocas
6
6
  Author-email: Gpocas <gpocas01@gmail.com>
@@ -45,7 +45,16 @@ As variáveis usam o prefixo `JAYLOG_`. Podem ser definidas no ambiente do siste
45
45
  | `JAYLOG_LOG_HTTP_ENDPOINT` | NÃO | `null` | URL do endpoint que receberá os logs |
46
46
  | `JAYLOG_LOG_HTTP_API_KEY` | NÃO | `null` | Chave de autenticação enviada no header `x-api-key` |
47
47
  | `JAYLOG_LOG_HTTP_PROXY` | NÃO | `null` | URL do proxy para o envio HTTP (ex: `http:\\user:password@server:port`) |
48
+ | `JAYLOG_LOG_HTTP_VERIFY` | NÃO | `false` | `true` ou caminho para o bundle de CA usado para validar TLS |
48
49
  | `JAYLOG_LOG_SCREENSHOT_ENABLED` | NÃO | `false` | Captura screenshot no momento do log (`true`/`false`, apenas Windows) |
50
+ | `JAYLOG_HOST_REPORT_ENABLED` | NÃO | `true` | Envia uma fotografia do ambiente no startup |
51
+ | `JAYLOG_HOST_HTTP_ENDPOINT` | NÃO | derivado | Override para o endpoint de host; por padrão `/logs/add` vira `/logs/host` |
52
+ | `JAYLOG_HOST_REPORT_TIMEOUT` | NÃO | `2 × HTTP_TIMEOUT` | Timeout do POST de ambiente |
53
+ | `JAYLOG_HOST_GIT_ENABLED` | NÃO | `true` | Coleta metadados do repositório Git |
54
+ | `JAYLOG_HOST_GIT_DIRTY_ENABLED` | NÃO | `true` | Coleta se há arquivos alterados |
55
+ | `JAYLOG_HOST_GIT_REMOTE_ENABLED`| NÃO | `true` | Coleta a URL remota sem credenciais |
56
+ | `JAYLOG_HOST_GIT_TIMEOUT` | NÃO | `3.0` | Timeout, em segundos, de cada chamada ao Git |
57
+ | `JAYLOG_HOST_GIT_DIR` | NÃO | `null` | Diretório inicial para localizar o repositório |
49
58
 
50
59
 
51
60
  ## Como usar?
@@ -121,6 +130,7 @@ from jaylog import get_logger
121
130
 
122
131
  logger = get_logger()
123
132
 
133
+
124
134
  def parse_csv():
125
135
  logger.info("Múltiplos Arquivos - parse.py")
126
136
  ```
@@ -138,12 +148,12 @@ __*main.py*__
138
148
  ```python
139
149
  from jaylog import JaylogSettings, configure, get_logger
140
150
 
141
- settings_order = JaylogSettings(app_name="ORDER-PROCESSOR")
151
+ settings_order = JaylogSettings(app_name="ORDER-PROCESSOR")
142
152
  settings_billing = JaylogSettings(app_name="BILLING")
143
153
 
144
154
  configure([settings_order, settings_billing])
145
155
 
146
- logger = get_logger("ORDER-PROCESSOR") # ou get_logger() — retorna o primeiro registrado
156
+ logger = get_logger("ORDER-PROCESSOR") # ou get_logger() — retorna o primeiro registrado
147
157
  billing_logger = get_logger("BILLING")
148
158
 
149
159
  logger.info("Pedido recebido")
@@ -191,7 +201,7 @@ __*main.py*__
191
201
  ```python
192
202
  from jaylog import JaylogSettings, configure, get_logger
193
203
 
194
- configure(JaylogSettings(_env_file='development.env'))
204
+ configure(JaylogSettings(_env_file="development.env"))
195
205
  logger = get_logger()
196
206
 
197
207
  logger.info("Alterando Caminho padrão do .env")
@@ -200,6 +210,32 @@ logger.info("Alterando Caminho padrão do .env")
200
210
 
201
211
  ## Preparando para produção
202
212
 
213
+ ## Registro do ambiente
214
+
215
+ Na versão 0.3, `configure()` inicia em segundo plano um único `POST` JSON para
216
+ o endpoint de host por serviço. O corpo dos logs continua compatível com a
217
+ linha 0.2.x; apenas os headers `x-jaylog-protocol: 2` e `x-jaylog-run-id` são
218
+ adicionados. O `run_id` permite ao backend ligar os logs à execução que os
219
+ produziu.
220
+
221
+ O endpoint é derivado automaticamente trocando o último segmento de
222
+ `JAYLOG_LOG_HTTP_ENDPOINT`: `https://api.example/logs/add` vira
223
+ `https://api.example/logs/host`. Use `JAYLOG_HOST_HTTP_ENDPOINT` somente quando
224
+ o backend publicar a rota em outro endereço.
225
+
226
+ O registro inclui sistema operacional, modo de execução, Python, virtualenv,
227
+ Git e diretórios de execução. A URL remota do Git tem sempre a credencial
228
+ embutida removida antes de sair da máquina. Falhas de coleta ou de rede não
229
+ interrompem a aplicação: o envio tenta novamente com backoff. Se o backend
230
+ aceitar um log mas devolver `x-jaylog-host-required: 1`, o jaylog reenvia o
231
+ registro de host com debounce de 30 segundos; o log já foi aceito e não é
232
+ reenviado.
233
+
234
+ `JAYLOG_LOG_HTTP_VERIFY=false` continua sendo o padrão desta versão para não
235
+ interromper instalações atrás de proxies corporativos. Para validar TLS, use
236
+ `JAYLOG_LOG_HTTP_VERIFY=true` ou informe o caminho do CA bundle corporativo.
237
+ O padrão passará a `true` na 0.4.0.
238
+
203
239
  > [!IMPORTANT]
204
240
  > **HTTP_ENDPOINT** e **HTTP_API_KEY** (opcionais) 📢
205
241
  >
@@ -232,10 +268,7 @@ __*main.py*__
232
268
  ```python
233
269
  from jaylog import JaylogSettings, configure, get_logger
234
270
 
235
- configure(JaylogSettings(
236
- _env_file='prodution.env',
237
- _secrets_dir='/foo/bar/secrets/'
238
- ))
271
+ configure(JaylogSettings(_env_file="prodution.env", _secrets_dir="/foo/bar/secrets/"))
239
272
  logger = get_logger()
240
273
 
241
274
  logger.info("Mensagem de log")
@@ -256,7 +289,7 @@ from jaylog import JaylogSettings, configure, get_logger
256
289
 
257
290
  # nesse caso é necessário usar a função de classe `reload_secrets`
258
291
  # pois o diretorio dos secrets foi passado via variável de ambiente
259
- configure(JaylogSettings(_env_file='prodution.env').reload_secrets())
292
+ configure(JaylogSettings(_env_file="prodution.env").reload_secrets())
260
293
  logger = get_logger()
261
294
 
262
295
  logger.info("Mensagem de log")
@@ -265,7 +298,7 @@ logger.info("Mensagem de log")
265
298
  `reload_secrets()` devolve uma **nova** instância preservando tudo que foi passado explicitamente no construtor, então dá para combinar configuração em código com secrets em disco:
266
299
 
267
300
  ```python
268
- settings = JaylogSettings(app_name='meu-bot', log_level='DEBUG').reload_secrets()
301
+ settings = JaylogSettings(app_name="meu-bot", log_level="DEBUG").reload_secrets()
269
302
  configure(settings) # app_name e log_level mantidos; endpoint/api_key vêm dos secrets
270
303
  ```
271
304
 
@@ -277,10 +310,10 @@ configure(settings) # app_name e log_level mantidos; endpoint/api_key vêm dos
277
310
  `reconfigure()` recria a configuração com os mesmos argumentos do construtor, aplicando apenas os overrides informados. Vale tanto para campos quanto para os argumentos de configuração do pydantic-settings (`_env_file`, `_secrets_dir`, `_case_sensitive`, `_env_prefix`, ...):
278
311
 
279
312
  ```python
280
- base = JaylogSettings(app_name='meu-bot', log_level='DEBUG')
313
+ base = JaylogSettings(app_name="meu-bot", log_level="DEBUG")
281
314
 
282
- homolog = base.reconfigure(_env_file='homolog.env')
283
- prod = base.reconfigure(_env_file='producao.env', log_level='WARNING')
315
+ homolog = base.reconfigure(_env_file="homolog.env")
316
+ prod = base.reconfigure(_env_file="producao.env", log_level="WARNING")
284
317
  # app_name preservado nos dois; só o que foi informado muda
285
318
  ```
286
319
 
@@ -26,7 +26,16 @@ As variáveis usam o prefixo `JAYLOG_`. Podem ser definidas no ambiente do siste
26
26
  | `JAYLOG_LOG_HTTP_ENDPOINT` | NÃO | `null` | URL do endpoint que receberá os logs |
27
27
  | `JAYLOG_LOG_HTTP_API_KEY` | NÃO | `null` | Chave de autenticação enviada no header `x-api-key` |
28
28
  | `JAYLOG_LOG_HTTP_PROXY` | NÃO | `null` | URL do proxy para o envio HTTP (ex: `http:\\user:password@server:port`) |
29
+ | `JAYLOG_LOG_HTTP_VERIFY` | NÃO | `false` | `true` ou caminho para o bundle de CA usado para validar TLS |
29
30
  | `JAYLOG_LOG_SCREENSHOT_ENABLED` | NÃO | `false` | Captura screenshot no momento do log (`true`/`false`, apenas Windows) |
31
+ | `JAYLOG_HOST_REPORT_ENABLED` | NÃO | `true` | Envia uma fotografia do ambiente no startup |
32
+ | `JAYLOG_HOST_HTTP_ENDPOINT` | NÃO | derivado | Override para o endpoint de host; por padrão `/logs/add` vira `/logs/host` |
33
+ | `JAYLOG_HOST_REPORT_TIMEOUT` | NÃO | `2 × HTTP_TIMEOUT` | Timeout do POST de ambiente |
34
+ | `JAYLOG_HOST_GIT_ENABLED` | NÃO | `true` | Coleta metadados do repositório Git |
35
+ | `JAYLOG_HOST_GIT_DIRTY_ENABLED` | NÃO | `true` | Coleta se há arquivos alterados |
36
+ | `JAYLOG_HOST_GIT_REMOTE_ENABLED`| NÃO | `true` | Coleta a URL remota sem credenciais |
37
+ | `JAYLOG_HOST_GIT_TIMEOUT` | NÃO | `3.0` | Timeout, em segundos, de cada chamada ao Git |
38
+ | `JAYLOG_HOST_GIT_DIR` | NÃO | `null` | Diretório inicial para localizar o repositório |
30
39
 
31
40
 
32
41
  ## Como usar?
@@ -102,6 +111,7 @@ from jaylog import get_logger
102
111
 
103
112
  logger = get_logger()
104
113
 
114
+
105
115
  def parse_csv():
106
116
  logger.info("Múltiplos Arquivos - parse.py")
107
117
  ```
@@ -119,12 +129,12 @@ __*main.py*__
119
129
  ```python
120
130
  from jaylog import JaylogSettings, configure, get_logger
121
131
 
122
- settings_order = JaylogSettings(app_name="ORDER-PROCESSOR")
132
+ settings_order = JaylogSettings(app_name="ORDER-PROCESSOR")
123
133
  settings_billing = JaylogSettings(app_name="BILLING")
124
134
 
125
135
  configure([settings_order, settings_billing])
126
136
 
127
- logger = get_logger("ORDER-PROCESSOR") # ou get_logger() — retorna o primeiro registrado
137
+ logger = get_logger("ORDER-PROCESSOR") # ou get_logger() — retorna o primeiro registrado
128
138
  billing_logger = get_logger("BILLING")
129
139
 
130
140
  logger.info("Pedido recebido")
@@ -172,7 +182,7 @@ __*main.py*__
172
182
  ```python
173
183
  from jaylog import JaylogSettings, configure, get_logger
174
184
 
175
- configure(JaylogSettings(_env_file='development.env'))
185
+ configure(JaylogSettings(_env_file="development.env"))
176
186
  logger = get_logger()
177
187
 
178
188
  logger.info("Alterando Caminho padrão do .env")
@@ -181,6 +191,32 @@ logger.info("Alterando Caminho padrão do .env")
181
191
 
182
192
  ## Preparando para produção
183
193
 
194
+ ## Registro do ambiente
195
+
196
+ Na versão 0.3, `configure()` inicia em segundo plano um único `POST` JSON para
197
+ o endpoint de host por serviço. O corpo dos logs continua compatível com a
198
+ linha 0.2.x; apenas os headers `x-jaylog-protocol: 2` e `x-jaylog-run-id` são
199
+ adicionados. O `run_id` permite ao backend ligar os logs à execução que os
200
+ produziu.
201
+
202
+ O endpoint é derivado automaticamente trocando o último segmento de
203
+ `JAYLOG_LOG_HTTP_ENDPOINT`: `https://api.example/logs/add` vira
204
+ `https://api.example/logs/host`. Use `JAYLOG_HOST_HTTP_ENDPOINT` somente quando
205
+ o backend publicar a rota em outro endereço.
206
+
207
+ O registro inclui sistema operacional, modo de execução, Python, virtualenv,
208
+ Git e diretórios de execução. A URL remota do Git tem sempre a credencial
209
+ embutida removida antes de sair da máquina. Falhas de coleta ou de rede não
210
+ interrompem a aplicação: o envio tenta novamente com backoff. Se o backend
211
+ aceitar um log mas devolver `x-jaylog-host-required: 1`, o jaylog reenvia o
212
+ registro de host com debounce de 30 segundos; o log já foi aceito e não é
213
+ reenviado.
214
+
215
+ `JAYLOG_LOG_HTTP_VERIFY=false` continua sendo o padrão desta versão para não
216
+ interromper instalações atrás de proxies corporativos. Para validar TLS, use
217
+ `JAYLOG_LOG_HTTP_VERIFY=true` ou informe o caminho do CA bundle corporativo.
218
+ O padrão passará a `true` na 0.4.0.
219
+
184
220
  > [!IMPORTANT]
185
221
  > **HTTP_ENDPOINT** e **HTTP_API_KEY** (opcionais) 📢
186
222
  >
@@ -213,10 +249,7 @@ __*main.py*__
213
249
  ```python
214
250
  from jaylog import JaylogSettings, configure, get_logger
215
251
 
216
- configure(JaylogSettings(
217
- _env_file='prodution.env',
218
- _secrets_dir='/foo/bar/secrets/'
219
- ))
252
+ configure(JaylogSettings(_env_file="prodution.env", _secrets_dir="/foo/bar/secrets/"))
220
253
  logger = get_logger()
221
254
 
222
255
  logger.info("Mensagem de log")
@@ -237,7 +270,7 @@ from jaylog import JaylogSettings, configure, get_logger
237
270
 
238
271
  # nesse caso é necessário usar a função de classe `reload_secrets`
239
272
  # pois o diretorio dos secrets foi passado via variável de ambiente
240
- configure(JaylogSettings(_env_file='prodution.env').reload_secrets())
273
+ configure(JaylogSettings(_env_file="prodution.env").reload_secrets())
241
274
  logger = get_logger()
242
275
 
243
276
  logger.info("Mensagem de log")
@@ -246,7 +279,7 @@ logger.info("Mensagem de log")
246
279
  `reload_secrets()` devolve uma **nova** instância preservando tudo que foi passado explicitamente no construtor, então dá para combinar configuração em código com secrets em disco:
247
280
 
248
281
  ```python
249
- settings = JaylogSettings(app_name='meu-bot', log_level='DEBUG').reload_secrets()
282
+ settings = JaylogSettings(app_name="meu-bot", log_level="DEBUG").reload_secrets()
250
283
  configure(settings) # app_name e log_level mantidos; endpoint/api_key vêm dos secrets
251
284
  ```
252
285
 
@@ -258,10 +291,10 @@ configure(settings) # app_name e log_level mantidos; endpoint/api_key vêm dos
258
291
  `reconfigure()` recria a configuração com os mesmos argumentos do construtor, aplicando apenas os overrides informados. Vale tanto para campos quanto para os argumentos de configuração do pydantic-settings (`_env_file`, `_secrets_dir`, `_case_sensitive`, `_env_prefix`, ...):
259
292
 
260
293
  ```python
261
- base = JaylogSettings(app_name='meu-bot', log_level='DEBUG')
294
+ base = JaylogSettings(app_name="meu-bot", log_level="DEBUG")
262
295
 
263
- homolog = base.reconfigure(_env_file='homolog.env')
264
- prod = base.reconfigure(_env_file='producao.env', log_level='WARNING')
296
+ homolog = base.reconfigure(_env_file="homolog.env")
297
+ prod = base.reconfigure(_env_file="producao.env", log_level="WARNING")
265
298
  # app_name preservado nos dois; só o que foi informado muda
266
299
  ```
267
300
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jaylog"
3
- version = "0.2.8"
3
+ version = "0.3.0a0"
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.formatters import build_log_entry_dict
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 = build_log_entry_dict(record)
41
- log_timestamp = datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
42
- log_level = f'[{entry["log_level"]}]'
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['log_level']))
45
- service_segment = f'[{entry["service"]}] | ' if self.show_service else ''
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`.