logcore 0.1.2__py3-none-any.whl
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.
- logcore-0.1.2.dist-info/METADATA +433 -0
- logcore-0.1.2.dist-info/RECORD +11 -0
- logcore-0.1.2.dist-info/WHEEL +5 -0
- logcore-0.1.2.dist-info/licenses/LICENSE +21 -0
- logcore-0.1.2.dist-info/top_level.txt +1 -0
- logforge/__init__.py +6 -0
- logforge/config.py +104 -0
- logforge/formatters.py +164 -0
- logforge/handlers.py +55 -0
- logforge/logger.py +152 -0
- logforge/utils.py +153 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logcore
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A production-ready structured logging library for Python
|
|
5
|
+
Author-email: LogForge Contributors <contributors@logforge.dev>
|
|
6
|
+
Maintainer-email: LogForge Contributors <contributors@logforge.dev>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 LogForge Contributors
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Project-URL: Homepage, https://github.com/SarkarRana/logforge
|
|
30
|
+
Project-URL: Bug Reports, https://github.com/SarkarRana/logforge/issues
|
|
31
|
+
Project-URL: Source, https://github.com/SarkarRana/logforge
|
|
32
|
+
Project-URL: Documentation, https://github.com/SarkarRana/logforge#readme
|
|
33
|
+
Keywords: logging,structured-logging,json,observability,microservices
|
|
34
|
+
Classifier: Development Status :: 4 - Beta
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Classifier: Topic :: System :: Logging
|
|
46
|
+
Requires-Python: >=3.8
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
License-File: LICENSE
|
|
49
|
+
Provides-Extra: colors
|
|
50
|
+
Requires-Dist: colorama>=0.4.0; extra == "colors"
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
53
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
54
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
55
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
56
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
57
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
58
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
59
|
+
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
60
|
+
Provides-Extra: docs
|
|
61
|
+
Requires-Dist: sphinx>=6.0.0; extra == "docs"
|
|
62
|
+
Requires-Dist: sphinx-rtd-theme>=1.2.0; extra == "docs"
|
|
63
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.19.0; extra == "docs"
|
|
64
|
+
Dynamic: license-file
|
|
65
|
+
|
|
66
|
+
# LogForge ๐ฅ
|
|
67
|
+
|
|
68
|
+
[](https://badge.fury.io/py/logcore)
|
|
69
|
+
[](https://pypi.org/project/logcore/)
|
|
70
|
+
[](https://github.com/SarkarRana/logforge/actions/workflows/ci.yml)
|
|
71
|
+
[](https://github.com/SarkarRana/logforge/blob/main/LICENSE)
|
|
72
|
+
|
|
73
|
+
**A production-ready logging library for Python**
|
|
74
|
+
|
|
75
|
+
LogForge provides a simple, structured, and extensible logging solution that works seamlessly for both small scripts and large microservices. It's designed as a drop-in alternative to Python's built-in logging with a focus on developer experience, observability, and production readiness.
|
|
76
|
+
|
|
77
|
+
## โจ Features
|
|
78
|
+
|
|
79
|
+
- **๐ Simple API**: Single entrypoint with intuitive configuration
|
|
80
|
+
- **๐ Structured Logging**: JSON and human-readable output formats
|
|
81
|
+
- **๐ Correlation IDs**: Built-in request tracing support
|
|
82
|
+
- **โฑ๏ธ Built-in Timing**: Context managers for performance monitoring
|
|
83
|
+
- **๐ก๏ธ Security**: Automatic redaction of sensitive fields
|
|
84
|
+
- **๐ File Rotation**: Configurable log rotation and archival
|
|
85
|
+
- **๐จ Colorized Output**: Beautiful console logging with colors
|
|
86
|
+
- **โก Async Support**: Safe for asyncio applications
|
|
87
|
+
- **๐งต Thread-safe**: Concurrent logging without issues
|
|
88
|
+
- **๐ Environment Configuration**: Configure via environment variables
|
|
89
|
+
|
|
90
|
+
## ๐ Quick Start
|
|
91
|
+
|
|
92
|
+
### Installation
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install logcore
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For colored output support:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install logforge[colors]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Basic Usage
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from logforge import get_logger
|
|
108
|
+
|
|
109
|
+
# Create a logger
|
|
110
|
+
log = get_logger("myapp", level="INFO", json=True)
|
|
111
|
+
|
|
112
|
+
# Simple logging
|
|
113
|
+
log.info("Application started")
|
|
114
|
+
log.error("Something went wrong")
|
|
115
|
+
|
|
116
|
+
# Structured logging with extra fields
|
|
117
|
+
log.info("User login", user="alice", role="admin", success=True)
|
|
118
|
+
|
|
119
|
+
# Exception logging with automatic traceback
|
|
120
|
+
try:
|
|
121
|
+
1 / 0
|
|
122
|
+
except Exception:
|
|
123
|
+
log.exception("Division failed")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## ๐ Documentation
|
|
127
|
+
|
|
128
|
+
### Configuration Options
|
|
129
|
+
|
|
130
|
+
LogForge can be configured through code or environment variables:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from logforge import get_logger
|
|
134
|
+
|
|
135
|
+
log = get_logger(
|
|
136
|
+
name="myapp", # Logger name
|
|
137
|
+
level="INFO", # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
138
|
+
json=True, # JSON output (False for human-readable)
|
|
139
|
+
file="/path/to/app.log", # Optional file logging
|
|
140
|
+
correlation_id="req-123", # Optional correlation ID
|
|
141
|
+
max_file_size=10*1024*1024, # 10MB file size limit
|
|
142
|
+
backup_count=5, # Keep 5 backup files
|
|
143
|
+
redact_fields={"password", "secret"} # Fields to redact
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Environment Variables
|
|
148
|
+
|
|
149
|
+
Set configuration via environment variables:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
export LOGFORGE_LEVEL=DEBUG
|
|
153
|
+
export LOGFORGE_JSON=true
|
|
154
|
+
export LOGFORGE_FILE=/var/log/app.log
|
|
155
|
+
export LOGFORGE_CORRELATION_ID=req-abc-123
|
|
156
|
+
export LOGFORGE_REDACT_FIELDS=password,token,secret
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Output Formats
|
|
160
|
+
|
|
161
|
+
#### JSON Format
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"timestamp": "2025-01-15T10:30:45.123456",
|
|
166
|
+
"level": "INFO",
|
|
167
|
+
"logger": "myapp",
|
|
168
|
+
"message": "User login",
|
|
169
|
+
"correlation_id": "req-123",
|
|
170
|
+
"user": "alice",
|
|
171
|
+
"success": true
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Human-Readable Format
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
2025-01-15 10:30:45.123 INFO myapp [cid=req-123]: User login user=alice success=true
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Advanced Features
|
|
182
|
+
|
|
183
|
+
#### Correlation IDs for Request Tracing
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from logforge import get_logger
|
|
187
|
+
|
|
188
|
+
log = get_logger("api")
|
|
189
|
+
|
|
190
|
+
# Set correlation ID for the entire request context
|
|
191
|
+
with log.with_correlation_id("req-abc-123"):
|
|
192
|
+
log.info("Processing request")
|
|
193
|
+
process_request()
|
|
194
|
+
log.info("Request completed")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
#### Performance Timing
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Measure execution time automatically
|
|
201
|
+
with log.time("database_query", level="DEBUG"):
|
|
202
|
+
result = expensive_database_operation()
|
|
203
|
+
|
|
204
|
+
# Outputs:
|
|
205
|
+
# Starting database_query
|
|
206
|
+
# Completed database_query duration_ms=234.56
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Exception Handling
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
try:
|
|
213
|
+
risky_operation()
|
|
214
|
+
except Exception as e:
|
|
215
|
+
log.exception("Operation failed", operation="risky_operation", user_id=123)
|
|
216
|
+
# Automatically includes full traceback
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### Sensitive Data Redaction
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# Configure fields to automatically redact
|
|
223
|
+
log = get_logger("secure", redact_fields={"password", "token", "ssn"})
|
|
224
|
+
|
|
225
|
+
log.info("User data", username="alice", password="secret123", role="admin")
|
|
226
|
+
# Output: ... username=alice password=[REDACTED] role=admin
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### File Logging with Rotation
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
log = get_logger(
|
|
233
|
+
"myapp",
|
|
234
|
+
file="/var/log/myapp.log",
|
|
235
|
+
max_file_size=10 * 1024 * 1024, # 10MB
|
|
236
|
+
backup_count=5 # Keep 5 old files
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Files are automatically rotated:
|
|
241
|
+
|
|
242
|
+
- `myapp.log` (current)
|
|
243
|
+
- `myapp.log.1` (previous)
|
|
244
|
+
- `myapp.log.2` (older)
|
|
245
|
+
- etc.
|
|
246
|
+
|
|
247
|
+
### Async Support
|
|
248
|
+
|
|
249
|
+
LogForge is fully compatible with asyncio:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
import asyncio
|
|
253
|
+
from logforge import get_logger
|
|
254
|
+
|
|
255
|
+
async def main():
|
|
256
|
+
log = get_logger("async_app")
|
|
257
|
+
|
|
258
|
+
# Correlation IDs work across await boundaries
|
|
259
|
+
with log.with_correlation_id():
|
|
260
|
+
log.info("Starting async operation")
|
|
261
|
+
await some_async_task()
|
|
262
|
+
log.info("Async operation completed")
|
|
263
|
+
|
|
264
|
+
# Async timing context manager
|
|
265
|
+
async with log.time("async_operation"):
|
|
266
|
+
await another_async_task()
|
|
267
|
+
|
|
268
|
+
asyncio.run(main())
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Integration with Web Frameworks
|
|
272
|
+
|
|
273
|
+
#### Flask Example
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
from flask import Flask, request, g
|
|
277
|
+
from logforge import get_logger
|
|
278
|
+
import uuid
|
|
279
|
+
|
|
280
|
+
app = Flask(__name__)
|
|
281
|
+
log = get_logger("webapp")
|
|
282
|
+
|
|
283
|
+
@app.before_request
|
|
284
|
+
def before_request():
|
|
285
|
+
g.correlation_id = request.headers.get('X-Correlation-ID', str(uuid.uuid4()))
|
|
286
|
+
|
|
287
|
+
@app.after_request
|
|
288
|
+
def after_request(response):
|
|
289
|
+
with log.with_correlation_id(g.correlation_id):
|
|
290
|
+
log.info(
|
|
291
|
+
"Request completed",
|
|
292
|
+
method=request.method,
|
|
293
|
+
path=request.path,
|
|
294
|
+
status_code=response.status_code,
|
|
295
|
+
duration_ms=... # Add timing logic
|
|
296
|
+
)
|
|
297
|
+
return response
|
|
298
|
+
|
|
299
|
+
@app.route('/users/<user_id>')
|
|
300
|
+
def get_user(user_id):
|
|
301
|
+
with log.with_correlation_id(g.correlation_id):
|
|
302
|
+
log.info("Fetching user", user_id=user_id)
|
|
303
|
+
# ... your logic here
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### FastAPI Example
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
from fastapi import FastAPI, Request
|
|
310
|
+
from logforge import get_logger
|
|
311
|
+
import time
|
|
312
|
+
import uuid
|
|
313
|
+
|
|
314
|
+
app = FastAPI()
|
|
315
|
+
log = get_logger("api")
|
|
316
|
+
|
|
317
|
+
@app.middleware("http")
|
|
318
|
+
async def logging_middleware(request: Request, call_next):
|
|
319
|
+
correlation_id = request.headers.get("x-correlation-id", str(uuid.uuid4()))
|
|
320
|
+
start_time = time.time()
|
|
321
|
+
|
|
322
|
+
with log.with_correlation_id(correlation_id):
|
|
323
|
+
log.info("Request started", method=request.method, url=str(request.url))
|
|
324
|
+
|
|
325
|
+
response = await call_next(request)
|
|
326
|
+
|
|
327
|
+
duration = (time.time() - start_time) * 1000
|
|
328
|
+
log.info(
|
|
329
|
+
"Request completed",
|
|
330
|
+
status_code=response.status_code,
|
|
331
|
+
duration_ms=round(duration, 2)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
response.headers["x-correlation-id"] = correlation_id
|
|
335
|
+
return response
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## ๐ Comparison with Other Libraries
|
|
339
|
+
|
|
340
|
+
### vs. Built-in `logging`
|
|
341
|
+
|
|
342
|
+
| Feature | LogForge | Built-in logging |
|
|
343
|
+
| ------------------ | ------------------------- | ---------------------------- |
|
|
344
|
+
| Setup complexity | โญโญโญโญโญ Single line | โญโญ Complex setup |
|
|
345
|
+
| Structured logging | โญโญโญโญโญ Built-in | โญโญ Manual implementation |
|
|
346
|
+
| JSON output | โญโญโญโญโญ Automatic | โญโญ Custom formatter needed |
|
|
347
|
+
| Correlation IDs | โญโญโญโญโญ Built-in | โญ Custom context needed |
|
|
348
|
+
| Security | โญโญโญโญโญ Auto-redaction | โญ Manual filtering |
|
|
349
|
+
| Colors | โญโญโญโญโญ Auto-detected | โญโญ Third-party needed |
|
|
350
|
+
|
|
351
|
+
### vs. `loguru`
|
|
352
|
+
|
|
353
|
+
| Feature | LogForge | Loguru |
|
|
354
|
+
| ---------------- | --------------------------- | ------------------------------ |
|
|
355
|
+
| Production focus | โญโญโญโญโญ Enterprise-ready | โญโญโญโญ Great for development |
|
|
356
|
+
| Correlation IDs | โญโญโญโญโญ Built-in context | โญโญ Manual binding |
|
|
357
|
+
| Security | โญโญโญโญโญ Auto-redaction | โญโญ Manual filtering |
|
|
358
|
+
| Async support | โญโญโญโญโญ Context-aware | โญโญโญ Basic support |
|
|
359
|
+
| Performance | โญโญโญโญ Good | โญโญโญโญโญ Excellent |
|
|
360
|
+
| Ecosystem | โญโญโญโญโญ Standard logging | โญโญโญ Custom approach |
|
|
361
|
+
|
|
362
|
+
## ๐ ๏ธ Development
|
|
363
|
+
|
|
364
|
+
### Setup
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
git clone https://github.com/logforge/logforge.git
|
|
368
|
+
cd logforge
|
|
369
|
+
|
|
370
|
+
# Install development dependencies
|
|
371
|
+
pip install -e ".[dev]"
|
|
372
|
+
|
|
373
|
+
# Install pre-commit hooks
|
|
374
|
+
pre-commit install
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Running Tests
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
# Run all tests
|
|
381
|
+
pytest
|
|
382
|
+
|
|
383
|
+
# Run with coverage
|
|
384
|
+
pytest --cov=logforge
|
|
385
|
+
|
|
386
|
+
# Run specific test categories
|
|
387
|
+
pytest -m "not slow" # Skip slow tests
|
|
388
|
+
pytest -m integration # Run integration tests only
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Code Quality
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
# Format code
|
|
395
|
+
black logforge tests
|
|
396
|
+
isort logforge tests
|
|
397
|
+
|
|
398
|
+
# Lint
|
|
399
|
+
flake8 logforge tests
|
|
400
|
+
|
|
401
|
+
# Type checking
|
|
402
|
+
mypy logforge
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## ๐ License
|
|
406
|
+
|
|
407
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
408
|
+
|
|
409
|
+
## ๐ค Contributing
|
|
410
|
+
|
|
411
|
+
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
|
412
|
+
|
|
413
|
+
## ๐ฏ Roadmap
|
|
414
|
+
|
|
415
|
+
- [ ] **Performance Optimizations**: Async batching, lazy formatting
|
|
416
|
+
- [ ] **Integrations**: OpenTelemetry, Sentry, DataDog
|
|
417
|
+
- [ ] **Advanced Features**: Log sampling, rate limiting
|
|
418
|
+
- [ ] **Cloud Native**: Kubernetes-friendly output formats
|
|
419
|
+
- [ ] **Monitoring**: Health checks and metrics endpoints
|
|
420
|
+
|
|
421
|
+
## ๐ Support
|
|
422
|
+
|
|
423
|
+
If you find LogForge useful, please consider:
|
|
424
|
+
|
|
425
|
+
- โญ Starring the repository
|
|
426
|
+
- ๐ Reporting bugs and issues
|
|
427
|
+
- ๐ก Suggesting new features
|
|
428
|
+
- ๐ Improving documentation
|
|
429
|
+
- ๐ป Contributing code
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
**Built with โค๏ธ for the Python community**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
logcore-0.1.2.dist-info/licenses/LICENSE,sha256=3KBLf0BLAuWowqqVRuMAEVjazAW7Ch5QEDAdRdE1JUg,1078
|
|
2
|
+
logforge/__init__.py,sha256=kmTvxuXQSxQ17DI0-DCMxWJ2-I0MwY5nIN10pif1wkE,128
|
|
3
|
+
logforge/config.py,sha256=DLoCvwF7I1sa_fdns16Sq2MBU6eNopYOxYqud0BXppI,3336
|
|
4
|
+
logforge/formatters.py,sha256=uPOmi22oac_ZYa51TQbmXmTITbsbix2P5wECXWa1AF0,5507
|
|
5
|
+
logforge/handlers.py,sha256=WTxTvxn4hw0FTekUXRPZ0lD9T4vPftrrLxnaAmTX7PM,1726
|
|
6
|
+
logforge/logger.py,sha256=yFkRaRsAoDxjDkAypR0jidjpxr2J9p3-Smmr3MnhfF8,4899
|
|
7
|
+
logforge/utils.py,sha256=B5u6HxNCiWew7QAoRywuaMjwRjBDnDcVx-H0jkeOwqk,4354
|
|
8
|
+
logcore-0.1.2.dist-info/METADATA,sha256=y-cSuLzKvWZew4D-dF7cRXribF29bf5-cPJhKl86PFA,13194
|
|
9
|
+
logcore-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
logcore-0.1.2.dist-info/top_level.txt,sha256=1VZVvxZgyeINaGnJ-sWATfeY6mFec4MoylvU3nYdKt8,9
|
|
11
|
+
logcore-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 LogForge Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
logforge
|
logforge/__init__.py
ADDED
logforge/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Configuration management for LogForge."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict, Optional, Set
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LogLevel(Enum):
|
|
10
|
+
DEBUG = "DEBUG"
|
|
11
|
+
INFO = "INFO"
|
|
12
|
+
WARNING = "WARNING"
|
|
13
|
+
WARN = "WARN"
|
|
14
|
+
ERROR = "ERROR"
|
|
15
|
+
CRITICAL = "CRITICAL"
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_string(cls, level: str) -> "LogLevel":
|
|
19
|
+
level = level.upper()
|
|
20
|
+
if level == "WARN":
|
|
21
|
+
return cls.WARNING
|
|
22
|
+
return cls(level)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class LogForgeConfig:
|
|
27
|
+
name: str
|
|
28
|
+
level: LogLevel = LogLevel.INFO
|
|
29
|
+
json: bool = False
|
|
30
|
+
file: Optional[str] = None
|
|
31
|
+
correlation_id: Optional[str] = None
|
|
32
|
+
max_file_size: int = 10 * 1024 * 1024
|
|
33
|
+
backup_count: int = 5
|
|
34
|
+
redact_fields: Set[str] = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
if self.redact_fields is None:
|
|
38
|
+
self.redact_fields = {
|
|
39
|
+
"password", "passwd", "secret", "token", "key", "api_key",
|
|
40
|
+
"access_token", "auth", "authorization", "credential",
|
|
41
|
+
"private_key", "cert", "certificate"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_config_from_env() -> Dict[str, Any]:
|
|
46
|
+
config = {}
|
|
47
|
+
|
|
48
|
+
if level := os.getenv("LOGFORGE_LEVEL"):
|
|
49
|
+
try:
|
|
50
|
+
config["level"] = LogLevel.from_string(level)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
if json_env := os.getenv("LOGFORGE_JSON"):
|
|
55
|
+
config["json"] = json_env.lower() in ("true", "1", "yes", "on")
|
|
56
|
+
|
|
57
|
+
if file_path := os.getenv("LOGFORGE_FILE"):
|
|
58
|
+
config["file"] = file_path
|
|
59
|
+
|
|
60
|
+
if correlation_id := os.getenv("LOGFORGE_CORRELATION_ID"):
|
|
61
|
+
config["correlation_id"] = correlation_id
|
|
62
|
+
|
|
63
|
+
if max_size := os.getenv("LOGFORGE_MAX_FILE_SIZE"):
|
|
64
|
+
try:
|
|
65
|
+
config["max_file_size"] = int(max_size)
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
if backup_count := os.getenv("LOGFORGE_BACKUP_COUNT"):
|
|
70
|
+
try:
|
|
71
|
+
config["backup_count"] = int(backup_count)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
if redact_fields := os.getenv("LOGFORGE_REDACT_FIELDS"):
|
|
76
|
+
config["redact_fields"] = set(field.strip() for field in redact_fields.split(","))
|
|
77
|
+
|
|
78
|
+
return config
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_config(
|
|
82
|
+
name: str,
|
|
83
|
+
level: Optional[str] = None,
|
|
84
|
+
json: Optional[bool] = None,
|
|
85
|
+
file: Optional[str] = None,
|
|
86
|
+
correlation_id: Optional[str] = None,
|
|
87
|
+
max_file_size: Optional[int] = None,
|
|
88
|
+
backup_count: Optional[int] = None,
|
|
89
|
+
redact_fields: Optional[Set[str]] = None,
|
|
90
|
+
) -> LogForgeConfig:
|
|
91
|
+
env_config = get_config_from_env()
|
|
92
|
+
|
|
93
|
+
config_dict = {
|
|
94
|
+
"name": name,
|
|
95
|
+
"level": LogLevel.from_string(level) if level else env_config.get("level", LogLevel.INFO),
|
|
96
|
+
"json": json if json is not None else env_config.get("json", False),
|
|
97
|
+
"file": file if file is not None else env_config.get("file"),
|
|
98
|
+
"correlation_id": correlation_id if correlation_id is not None else env_config.get("correlation_id"),
|
|
99
|
+
"max_file_size": max_file_size if max_file_size is not None else env_config.get("max_file_size", 10 * 1024 * 1024),
|
|
100
|
+
"backup_count": backup_count if backup_count is not None else env_config.get("backup_count", 5),
|
|
101
|
+
"redact_fields": redact_fields if redact_fields is not None else env_config.get("redact_fields"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return LogForgeConfig(**config_dict)
|
logforge/formatters.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Formatters for LogForge logging output."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, Set
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import colorama
|
|
12
|
+
from colorama import Fore, Style
|
|
13
|
+
colorama.init()
|
|
14
|
+
HAS_COLORS = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_COLORS = False
|
|
17
|
+
|
|
18
|
+
class Fore:
|
|
19
|
+
RED = YELLOW = GREEN = BLUE = CYAN = MAGENTA = WHITE = ""
|
|
20
|
+
|
|
21
|
+
class Style:
|
|
22
|
+
RESET_ALL = BRIGHT = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedactingFormatter:
|
|
26
|
+
"""Base formatter with redaction."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, redact_fields: Set[str] = None):
|
|
29
|
+
self.redact_fields = redact_fields or set()
|
|
30
|
+
self.redact_pattern = self._build_pattern()
|
|
31
|
+
|
|
32
|
+
def _build_pattern(self):
|
|
33
|
+
if not self.redact_fields:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
fields = "|".join(re.escape(field) for field in self.redact_fields)
|
|
37
|
+
pattern = rf'("{fields}"|{fields})(\s*[:=]\s*)("[^"]*"|[^\s,\]}}]+)'
|
|
38
|
+
return re.compile(pattern, re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
def _redact_text(self, text: str) -> str:
|
|
41
|
+
if not self.redact_pattern:
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
def replace(match):
|
|
45
|
+
key = match.group(1)
|
|
46
|
+
sep = match.group(2)
|
|
47
|
+
return f'{key}{sep}"[REDACTED]"'
|
|
48
|
+
|
|
49
|
+
return self.redact_pattern.sub(replace, text)
|
|
50
|
+
|
|
51
|
+
def _redact_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
if not self.redact_fields:
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
result = {}
|
|
56
|
+
redact_keys = {field.lower() for field in self.redact_fields}
|
|
57
|
+
|
|
58
|
+
for key, value in data.items():
|
|
59
|
+
if key.lower() in redact_keys:
|
|
60
|
+
result[key] = "[REDACTED]"
|
|
61
|
+
elif isinstance(value, dict):
|
|
62
|
+
result[key] = self._redact_dict(value)
|
|
63
|
+
else:
|
|
64
|
+
result[key] = value
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JSONFormatter(RedactingFormatter, logging.Formatter):
|
|
70
|
+
"""JSON formatter for structured logging."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, redact_fields: Set[str] = None):
|
|
73
|
+
super().__init__(redact_fields=redact_fields)
|
|
74
|
+
logging.Formatter.__init__(self)
|
|
75
|
+
|
|
76
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
77
|
+
entry = {
|
|
78
|
+
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
79
|
+
"level": record.levelname,
|
|
80
|
+
"logger": record.name,
|
|
81
|
+
"message": record.getMessage(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if hasattr(record, "correlation_id") and record.correlation_id:
|
|
85
|
+
entry["correlation_id"] = record.correlation_id
|
|
86
|
+
|
|
87
|
+
skip_fields = {
|
|
88
|
+
"name", "msg", "args", "levelname", "levelno", "pathname",
|
|
89
|
+
"filename", "module", "lineno", "funcName", "created",
|
|
90
|
+
"msecs", "relativeCreated", "thread", "threadName",
|
|
91
|
+
"processName", "process", "message", "correlation_id"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for key, value in record.__dict__.items():
|
|
95
|
+
if key not in skip_fields:
|
|
96
|
+
entry[key] = value
|
|
97
|
+
|
|
98
|
+
if record.exc_info and record.exc_info != True:
|
|
99
|
+
entry["exception"] = self.formatException(record.exc_info)
|
|
100
|
+
|
|
101
|
+
entry = self._redact_dict(entry)
|
|
102
|
+
return json.dumps(entry, default=str, ensure_ascii=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TextFormatter(RedactingFormatter, logging.Formatter):
|
|
106
|
+
"""Text formatter with colors."""
|
|
107
|
+
|
|
108
|
+
COLORS = {
|
|
109
|
+
"DEBUG": Fore.CYAN,
|
|
110
|
+
"INFO": Fore.GREEN,
|
|
111
|
+
"WARNING": Fore.YELLOW,
|
|
112
|
+
"ERROR": Fore.RED,
|
|
113
|
+
"CRITICAL": Fore.RED + Style.BRIGHT,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def __init__(self, redact_fields: Set[str] = None, use_colors: bool = None):
|
|
117
|
+
super().__init__(redact_fields=redact_fields)
|
|
118
|
+
|
|
119
|
+
if use_colors is None:
|
|
120
|
+
use_colors = HAS_COLORS and hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
121
|
+
|
|
122
|
+
self.use_colors = use_colors
|
|
123
|
+
logging.Formatter.__init__(self)
|
|
124
|
+
|
|
125
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
126
|
+
timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
127
|
+
|
|
128
|
+
level = record.levelname
|
|
129
|
+
if self.use_colors:
|
|
130
|
+
color = self.COLORS.get(level, "")
|
|
131
|
+
level = f"{color}{level:8}{Style.RESET_ALL}"
|
|
132
|
+
else:
|
|
133
|
+
level = f"{level:8}"
|
|
134
|
+
|
|
135
|
+
message = record.getMessage()
|
|
136
|
+
|
|
137
|
+
correlation_part = ""
|
|
138
|
+
if hasattr(record, "correlation_id") and record.correlation_id:
|
|
139
|
+
correlation_part = f" [cid={record.correlation_id}]"
|
|
140
|
+
|
|
141
|
+
skip_fields = {
|
|
142
|
+
"name", "msg", "args", "levelname", "levelno", "pathname",
|
|
143
|
+
"filename", "module", "lineno", "funcName", "created",
|
|
144
|
+
"msecs", "relativeCreated", "thread", "threadName",
|
|
145
|
+
"processName", "process", "message", "correlation_id"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
extras = []
|
|
149
|
+
redact_keys = {field.lower() for field in self.redact_fields}
|
|
150
|
+
|
|
151
|
+
for key, value in record.__dict__.items():
|
|
152
|
+
if key not in skip_fields:
|
|
153
|
+
if key.lower() in redact_keys:
|
|
154
|
+
value = "[REDACTED]"
|
|
155
|
+
extras.append(f"{key}={value}")
|
|
156
|
+
|
|
157
|
+
extra_part = " " + " ".join(extras) if extras else ""
|
|
158
|
+
|
|
159
|
+
formatted = f"{timestamp} {level} {record.name}{correlation_part}: {message}{extra_part}"
|
|
160
|
+
|
|
161
|
+
if record.exc_info and record.exc_info != True:
|
|
162
|
+
formatted += "\n" + self.formatException(record.exc_info)
|
|
163
|
+
|
|
164
|
+
return self._redact_text(formatted)
|
logforge/handlers.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Custom handlers for LogForge."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .formatters import JSONFormatter, TextFormatter
|
|
10
|
+
from .config import LogForgeConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConsoleHandler:
|
|
14
|
+
def __init__(self, config: LogForgeConfig):
|
|
15
|
+
self.config = config
|
|
16
|
+
self.handler = logging.StreamHandler(sys.stderr)
|
|
17
|
+
formatter = JSONFormatter(redact_fields=config.redact_fields) if config.json else TextFormatter(redact_fields=config.redact_fields)
|
|
18
|
+
self.handler.setFormatter(formatter)
|
|
19
|
+
|
|
20
|
+
def get_handler(self) -> logging.Handler:
|
|
21
|
+
return self.handler
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FileHandler:
|
|
25
|
+
def __init__(self, config: LogForgeConfig, file_path: str):
|
|
26
|
+
self.config = config
|
|
27
|
+
|
|
28
|
+
file_path = Path(file_path)
|
|
29
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
self.handler = logging.handlers.RotatingFileHandler(
|
|
32
|
+
filename=str(file_path),
|
|
33
|
+
maxBytes=config.max_file_size,
|
|
34
|
+
backupCount=config.backup_count,
|
|
35
|
+
encoding="utf-8"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
formatter = JSONFormatter(redact_fields=config.redact_fields) if config.json else TextFormatter(redact_fields=config.redact_fields)
|
|
39
|
+
self.handler.setFormatter(formatter)
|
|
40
|
+
|
|
41
|
+
def get_handler(self) -> logging.Handler:
|
|
42
|
+
return self.handler
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_handlers(config: LogForgeConfig) -> list[logging.Handler]:
|
|
46
|
+
handlers = []
|
|
47
|
+
|
|
48
|
+
console_handler = ConsoleHandler(config)
|
|
49
|
+
handlers.append(console_handler.get_handler())
|
|
50
|
+
|
|
51
|
+
if config.file:
|
|
52
|
+
file_handler = FileHandler(config, config.file)
|
|
53
|
+
handlers.append(file_handler.get_handler())
|
|
54
|
+
|
|
55
|
+
return handlers
|
logforge/logger.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Core logging functionality for LogForge."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any, Dict, Optional, Set, Union
|
|
7
|
+
|
|
8
|
+
from .config import LogForgeConfig, LogLevel, create_config
|
|
9
|
+
from .handlers import create_handlers
|
|
10
|
+
from .utils import (
|
|
11
|
+
Timer, AsyncTimer, correlation_id_context, get_correlation_id,
|
|
12
|
+
set_correlation_id, safe_str, is_async_context
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_logger_lock = threading.RLock()
|
|
17
|
+
_loggers: Dict[str, "LogForgeLogger"] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LogForgeLogger:
|
|
21
|
+
def __init__(self, config: LogForgeConfig):
|
|
22
|
+
self.config = config
|
|
23
|
+
|
|
24
|
+
self._logger = logging.getLogger(f"logforge.{config.name}")
|
|
25
|
+
self._logger.setLevel(getattr(logging, config.level.value))
|
|
26
|
+
|
|
27
|
+
self._logger.handlers.clear()
|
|
28
|
+
|
|
29
|
+
handlers = create_handlers(config)
|
|
30
|
+
for handler in handlers:
|
|
31
|
+
handler.setLevel(getattr(logging, config.level.value))
|
|
32
|
+
self._logger.addHandler(handler)
|
|
33
|
+
|
|
34
|
+
if config.correlation_id:
|
|
35
|
+
set_correlation_id(config.correlation_id)
|
|
36
|
+
|
|
37
|
+
def _log(self, level: str, message: str, *args, **kwargs):
|
|
38
|
+
exc_info = kwargs.pop('exc_info', False)
|
|
39
|
+
|
|
40
|
+
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
41
|
+
|
|
42
|
+
if not self._logger.isEnabledFor(numeric_level):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
record = self._logger.makeRecord(
|
|
46
|
+
self._logger.name,
|
|
47
|
+
numeric_level,
|
|
48
|
+
"(unknown file)",
|
|
49
|
+
0,
|
|
50
|
+
message,
|
|
51
|
+
args,
|
|
52
|
+
exc_info=exc_info,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
correlation_id = get_correlation_id()
|
|
56
|
+
if correlation_id:
|
|
57
|
+
record.correlation_id = correlation_id
|
|
58
|
+
|
|
59
|
+
for key, value in kwargs.items():
|
|
60
|
+
if not hasattr(record, key):
|
|
61
|
+
setattr(record, key, safe_str(value))
|
|
62
|
+
|
|
63
|
+
self._logger.handle(record)
|
|
64
|
+
|
|
65
|
+
def debug(self, message: str, *args, **kwargs):
|
|
66
|
+
self._log("DEBUG", message, *args, **kwargs)
|
|
67
|
+
|
|
68
|
+
def info(self, message: str, *args, **kwargs):
|
|
69
|
+
self._log("INFO", message, *args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def warning(self, message: str, *args, **kwargs):
|
|
72
|
+
self._log("WARNING", message, *args, **kwargs)
|
|
73
|
+
|
|
74
|
+
def warn(self, message: str, *args, **kwargs):
|
|
75
|
+
self.warning(message, *args, **kwargs)
|
|
76
|
+
|
|
77
|
+
def error(self, message: str, *args, **kwargs):
|
|
78
|
+
self._log("ERROR", message, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
def critical(self, message: str, *args, **kwargs):
|
|
81
|
+
self._log("CRITICAL", message, *args, **kwargs)
|
|
82
|
+
|
|
83
|
+
def exception(self, message: str, *args, **kwargs):
|
|
84
|
+
kwargs['exc_info'] = True
|
|
85
|
+
self.error(message, *args, **kwargs)
|
|
86
|
+
|
|
87
|
+
def time(self, operation_name: str, level: str = "INFO", **kwargs) -> Union[Timer, AsyncTimer]:
|
|
88
|
+
if is_async_context():
|
|
89
|
+
return AsyncTimer(self, operation_name, level, **kwargs)
|
|
90
|
+
else:
|
|
91
|
+
return Timer(self, operation_name, level, **kwargs)
|
|
92
|
+
|
|
93
|
+
def with_correlation_id(self, correlation_id: Optional[str] = None):
|
|
94
|
+
return correlation_id_context(correlation_id)
|
|
95
|
+
|
|
96
|
+
def set_level(self, level: Union[str, LogLevel]):
|
|
97
|
+
if isinstance(level, str):
|
|
98
|
+
level = LogLevel.from_string(level)
|
|
99
|
+
|
|
100
|
+
self.config.level = level
|
|
101
|
+
numeric_level = getattr(logging, level.value)
|
|
102
|
+
|
|
103
|
+
self._logger.setLevel(numeric_level)
|
|
104
|
+
for handler in self._logger.handlers:
|
|
105
|
+
handler.setLevel(numeric_level)
|
|
106
|
+
|
|
107
|
+
def get_level(self) -> LogLevel:
|
|
108
|
+
return self.config.level
|
|
109
|
+
|
|
110
|
+
def is_enabled_for(self, level: Union[str, LogLevel]) -> bool:
|
|
111
|
+
if isinstance(level, str):
|
|
112
|
+
level = LogLevel.from_string(level)
|
|
113
|
+
|
|
114
|
+
numeric_level = getattr(logging, level.value)
|
|
115
|
+
return self._logger.isEnabledFor(numeric_level)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_logger(
|
|
119
|
+
name: str,
|
|
120
|
+
level: Optional[str] = None,
|
|
121
|
+
json: Optional[bool] = None,
|
|
122
|
+
file: Optional[str] = None,
|
|
123
|
+
correlation_id: Optional[str] = None,
|
|
124
|
+
max_file_size: Optional[int] = None,
|
|
125
|
+
backup_count: Optional[int] = None,
|
|
126
|
+
redact_fields: Optional[Set[str]] = None,
|
|
127
|
+
) -> LogForgeLogger:
|
|
128
|
+
with _logger_lock:
|
|
129
|
+
if name in _loggers:
|
|
130
|
+
existing_logger = _loggers[name]
|
|
131
|
+
|
|
132
|
+
if all(param is None for param in [
|
|
133
|
+
level, json, file, correlation_id, max_file_size,
|
|
134
|
+
backup_count, redact_fields
|
|
135
|
+
]):
|
|
136
|
+
return existing_logger
|
|
137
|
+
|
|
138
|
+
config = create_config(
|
|
139
|
+
name=name,
|
|
140
|
+
level=level,
|
|
141
|
+
json=json,
|
|
142
|
+
file=file,
|
|
143
|
+
correlation_id=correlation_id,
|
|
144
|
+
max_file_size=max_file_size,
|
|
145
|
+
backup_count=backup_count,
|
|
146
|
+
redact_fields=redact_fields,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
logger = LogForgeLogger(config)
|
|
150
|
+
_loggers[name] = logger
|
|
151
|
+
|
|
152
|
+
return logger
|
logforge/utils.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Utilities for LogForge."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any, Generator, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
correlation_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
12
|
+
'correlation_id', default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_correlation_id() -> str:
|
|
17
|
+
return str(uuid.uuid4())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_correlation_id(correlation_id: Optional[str] = None) -> str:
|
|
21
|
+
if correlation_id is None:
|
|
22
|
+
correlation_id = generate_correlation_id()
|
|
23
|
+
|
|
24
|
+
correlation_context.set(correlation_id)
|
|
25
|
+
return correlation_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_correlation_id() -> Optional[str]:
|
|
29
|
+
return correlation_context.get()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def correlation_id_context(correlation_id: Optional[str] = None) -> Generator[str, None, None]:
|
|
34
|
+
if correlation_id is None:
|
|
35
|
+
correlation_id = generate_correlation_id()
|
|
36
|
+
|
|
37
|
+
token = correlation_context.set(correlation_id)
|
|
38
|
+
try:
|
|
39
|
+
yield correlation_id
|
|
40
|
+
finally:
|
|
41
|
+
correlation_context.reset(token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Timer:
|
|
45
|
+
def __init__(self, logger, operation_name: str, level: str = "INFO", **kwargs):
|
|
46
|
+
self.logger = logger
|
|
47
|
+
self.operation_name = operation_name
|
|
48
|
+
self.level = level.upper()
|
|
49
|
+
self.extra_fields = kwargs
|
|
50
|
+
self.start_time = None
|
|
51
|
+
self.end_time = None
|
|
52
|
+
|
|
53
|
+
def __enter__(self):
|
|
54
|
+
self.start_time = time.perf_counter()
|
|
55
|
+
self.logger._log(
|
|
56
|
+
self.level,
|
|
57
|
+
f"Starting {self.operation_name}",
|
|
58
|
+
**self.extra_fields
|
|
59
|
+
)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
63
|
+
self.end_time = time.perf_counter()
|
|
64
|
+
duration = self.end_time - self.start_time
|
|
65
|
+
|
|
66
|
+
if exc_type is not None:
|
|
67
|
+
self.logger._log(
|
|
68
|
+
"ERROR",
|
|
69
|
+
f"Failed {self.operation_name}",
|
|
70
|
+
duration_ms=round(duration * 1000, 2),
|
|
71
|
+
exception=str(exc_val) if exc_val else None,
|
|
72
|
+
**self.extra_fields
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
self.logger._log(
|
|
76
|
+
self.level,
|
|
77
|
+
f"Completed {self.operation_name}",
|
|
78
|
+
duration_ms=round(duration * 1000, 2),
|
|
79
|
+
**self.extra_fields
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def elapsed(self) -> Optional[float]:
|
|
84
|
+
if self.start_time is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
end_time = self.end_time or time.perf_counter()
|
|
88
|
+
return end_time - self.start_time
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AsyncTimer:
|
|
92
|
+
def __init__(self, logger, operation_name: str, level: str = "INFO", **kwargs):
|
|
93
|
+
self.logger = logger
|
|
94
|
+
self.operation_name = operation_name
|
|
95
|
+
self.level = level.upper()
|
|
96
|
+
self.extra_fields = kwargs
|
|
97
|
+
self.start_time = None
|
|
98
|
+
self.end_time = None
|
|
99
|
+
|
|
100
|
+
async def __aenter__(self):
|
|
101
|
+
self.start_time = time.perf_counter()
|
|
102
|
+
self.logger._log(
|
|
103
|
+
self.level,
|
|
104
|
+
f"Starting {self.operation_name}",
|
|
105
|
+
**self.extra_fields
|
|
106
|
+
)
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
110
|
+
self.end_time = time.perf_counter()
|
|
111
|
+
duration = self.end_time - self.start_time
|
|
112
|
+
|
|
113
|
+
if exc_type is not None:
|
|
114
|
+
self.logger._log(
|
|
115
|
+
"ERROR",
|
|
116
|
+
f"Failed {self.operation_name}",
|
|
117
|
+
duration_ms=round(duration * 1000, 2),
|
|
118
|
+
exception=str(exc_val) if exc_val else None,
|
|
119
|
+
**self.extra_fields
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
self.logger._log(
|
|
123
|
+
self.level,
|
|
124
|
+
f"Completed {self.operation_name}",
|
|
125
|
+
duration_ms=round(duration * 1000, 2),
|
|
126
|
+
**self.extra_fields
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def elapsed(self) -> Optional[float]:
|
|
131
|
+
if self.start_time is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
end_time = self.end_time or time.perf_counter()
|
|
135
|
+
return end_time - self.start_time
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_async_context() -> bool:
|
|
139
|
+
try:
|
|
140
|
+
asyncio.current_task()
|
|
141
|
+
return True
|
|
142
|
+
except RuntimeError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def safe_str(obj: Any, max_length: int = 1000) -> str:
|
|
147
|
+
try:
|
|
148
|
+
text = str(obj)
|
|
149
|
+
if len(text) > max_length:
|
|
150
|
+
return text[:max_length] + "..."
|
|
151
|
+
return text
|
|
152
|
+
except Exception:
|
|
153
|
+
return f"<{type(obj).__name__} object>"
|