fastsqs 0.3.2__tar.gz → 0.3.4__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.
- {fastsqs-0.3.2/fastsqs.egg-info → fastsqs-0.3.4}/PKG-INFO +13 -1
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/app.py +67 -16
- fastsqs-0.3.4/fastsqs/concurrency/__init__.py +2 -0
- fastsqs-0.3.4/fastsqs/concurrency/concurrency.py +36 -0
- fastsqs-0.3.4/fastsqs/concurrency/decorators.py +20 -0
- fastsqs-0.3.4/fastsqs/concurrency.py +16 -0
- fastsqs-0.3.4/fastsqs/logger/__init__.py +1 -0
- fastsqs-0.3.4/fastsqs/logger/config.py +12 -0
- fastsqs-0.3.4/fastsqs/logger/elasticsearch_handler.py +56 -0
- fastsqs-0.3.4/fastsqs/logger/logger.py +53 -0
- fastsqs-0.3.4/fastsqs/logger/utils.py +41 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/base.py +9 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/error_handling.py +71 -7
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/idempotency.py +41 -2
- fastsqs-0.3.4/fastsqs/middleware/logging.py +140 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/parallelization.py +17 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/timing.py +10 -1
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/visibility.py +23 -14
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/presets.py +10 -2
- {fastsqs-0.3.2 → fastsqs-0.3.4/fastsqs.egg-info}/PKG-INFO +13 -1
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/SOURCES.txt +9 -0
- fastsqs-0.3.4/fastsqs.egg-info/requires.txt +23 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/setup.py +16 -2
- fastsqs-0.3.2/fastsqs/middleware/logging.py +0 -63
- fastsqs-0.3.2/fastsqs.egg-info/requires.txt +0 -9
- {fastsqs-0.3.2 → fastsqs-0.3.4}/LICENSE +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/MANIFEST.in +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/README.md +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/__init__.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/events.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/exceptions.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/__init__.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/__init__.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/entry.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/router.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/types.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/utils.py +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/dependency_links.txt +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/top_level.txt +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/pyproject.toml +0 -0
- {fastsqs-0.3.2 → fastsqs-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastsqs
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: FastAPI-like, modern async SQS message processing for Python with advanced features
|
|
5
5
|
Home-page: https://github.com/lafayettegabe/fastsqs
|
|
6
6
|
Author: Gabriel LaFayette
|
|
@@ -27,9 +27,21 @@ Requires-Dist: pydantic>=2.0.0
|
|
|
27
27
|
Provides-Extra: dynamodb
|
|
28
28
|
Requires-Dist: boto3>=1.26.0; extra == "dynamodb"
|
|
29
29
|
Requires-Dist: aioboto3>=12.0.0; extra == "dynamodb"
|
|
30
|
+
Provides-Extra: idempotency
|
|
31
|
+
Requires-Dist: boto3>=1.26.0; extra == "idempotency"
|
|
32
|
+
Requires-Dist: aioboto3>=12.0.0; extra == "idempotency"
|
|
33
|
+
Provides-Extra: telemetry
|
|
34
|
+
Requires-Dist: opentelemetry-sdk>=1.32.1; extra == "telemetry"
|
|
35
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.32.1; extra == "telemetry"
|
|
36
|
+
Requires-Dist: opentelemetry-instrumentation>=0.53b1; extra == "telemetry"
|
|
37
|
+
Requires-Dist: elasticsearch>=8.11.1; extra == "telemetry"
|
|
30
38
|
Provides-Extra: all
|
|
31
39
|
Requires-Dist: boto3>=1.26.0; extra == "all"
|
|
32
40
|
Requires-Dist: aioboto3>=12.0.0; extra == "all"
|
|
41
|
+
Requires-Dist: opentelemetry-sdk>=1.32.1; extra == "all"
|
|
42
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.32.1; extra == "all"
|
|
43
|
+
Requires-Dist: opentelemetry-instrumentation>=0.53b1; extra == "all"
|
|
44
|
+
Requires-Dist: elasticsearch>=8.11.1; extra == "all"
|
|
33
45
|
Dynamic: author
|
|
34
46
|
Dynamic: author-email
|
|
35
47
|
Dynamic: classifier
|
|
@@ -10,6 +10,7 @@ from .types import QueueType, Handler
|
|
|
10
10
|
from .exceptions import RouteNotFound, InvalidMessage
|
|
11
11
|
from .middleware import Middleware, run_middlewares
|
|
12
12
|
from .middleware.idempotency import IdempotencyHit
|
|
13
|
+
from .middleware.logging import LoggingMiddleware
|
|
13
14
|
from .routing import QueueRouter
|
|
14
15
|
from .utils import group_records_by_message_group, invoke_handler
|
|
15
16
|
from .presets import MiddlewarePreset
|
|
@@ -66,7 +67,7 @@ class FastSQS:
|
|
|
66
67
|
if variant not in self._route_lookup:
|
|
67
68
|
self._route_lookup[variant] = primary_type
|
|
68
69
|
elif self.debug:
|
|
69
|
-
|
|
70
|
+
self._log("warning", f"Message type variant conflicts with existing route", variant=variant)
|
|
70
71
|
|
|
71
72
|
return handler
|
|
72
73
|
|
|
@@ -92,7 +93,17 @@ class FastSQS:
|
|
|
92
93
|
self._routers.append(router)
|
|
93
94
|
|
|
94
95
|
def add_middleware(self, middleware: Middleware) -> None:
|
|
96
|
+
middleware._app = self
|
|
95
97
|
self._middlewares.append(middleware)
|
|
98
|
+
|
|
99
|
+
def use(self, middleware: Middleware) -> None:
|
|
100
|
+
self.add_middleware(middleware)
|
|
101
|
+
|
|
102
|
+
def _log(self, level: str, message: str, **data) -> None:
|
|
103
|
+
for middleware in self._middlewares:
|
|
104
|
+
if isinstance(middleware, LoggingMiddleware) and hasattr(middleware, 'log'):
|
|
105
|
+
middleware.log(level, message, **data)
|
|
106
|
+
return
|
|
96
107
|
|
|
97
108
|
def use_preset(self, preset: str, **kwargs) -> None:
|
|
98
109
|
if preset == "production":
|
|
@@ -110,7 +121,7 @@ class FastSQS:
|
|
|
110
121
|
def set_queue_type(self, queue_type: QueueType) -> None:
|
|
111
122
|
self.queue_type = queue_type
|
|
112
123
|
if self.debug:
|
|
113
|
-
|
|
124
|
+
self._log("info", f"Queue type set to: {queue_type.value}")
|
|
114
125
|
|
|
115
126
|
def is_fifo_queue(self) -> bool:
|
|
116
127
|
return self.queue_type == QueueType.FIFO
|
|
@@ -119,11 +130,16 @@ class FastSQS:
|
|
|
119
130
|
body_str = record.get("body", "")
|
|
120
131
|
msg_id = record.get("messageId") or record.get("message_id") or "UNKNOWN"
|
|
121
132
|
|
|
133
|
+
self._log("info", f"Starting record processing", msg_id=msg_id)
|
|
134
|
+
self._log("debug", f"Raw body", msg_id=msg_id, body=body_str[:500] + ('...' if len(body_str) > 500 else ''))
|
|
135
|
+
|
|
122
136
|
try:
|
|
123
137
|
payload = json.loads(body_str) if body_str else {}
|
|
124
138
|
if not isinstance(payload, dict):
|
|
125
139
|
raise InvalidMessage("Message body must be a JSON object")
|
|
140
|
+
self._log("debug", f"Parsed payload", msg_id=msg_id, payload=payload)
|
|
126
141
|
except json.JSONDecodeError as e:
|
|
142
|
+
self._log("error", f"JSON decode error", msg_id=msg_id, error=str(e))
|
|
127
143
|
raise InvalidMessage(f"Invalid JSON in message body: {e}")
|
|
128
144
|
|
|
129
145
|
ctx: Dict[str, Any] = {
|
|
@@ -141,15 +157,19 @@ class FastSQS:
|
|
|
141
157
|
"messageDeduplicationId": attributes.get("messageDeduplicationId"),
|
|
142
158
|
"queueType": "fifo"
|
|
143
159
|
}
|
|
160
|
+
self._log("debug", f"FIFO info", msg_id=msg_id, fifo_info=ctx["fifoInfo"])
|
|
144
161
|
|
|
145
162
|
err: Optional[Exception] = None
|
|
146
163
|
result: Any = None
|
|
147
164
|
|
|
148
165
|
try:
|
|
166
|
+
self._log("debug", f"Running 'before' middleware chain", msg_id=msg_id)
|
|
149
167
|
await run_middlewares(self._middlewares, "before", payload, record, context, ctx)
|
|
168
|
+
self._log("debug", f"'before' middleware chain completed", msg_id=msg_id)
|
|
150
169
|
except IdempotencyHit as idempotency_hit:
|
|
170
|
+
self._log("info", f"Idempotency hit, returning cached result", msg_id=msg_id, key=idempotency_hit.key)
|
|
151
171
|
if self.debug:
|
|
152
|
-
|
|
172
|
+
self._log("debug", f"Idempotency hit for message", msg_id=msg_id, key=idempotency_hit.key)
|
|
153
173
|
ctx["idempotency_result"] = idempotency_hit.result
|
|
154
174
|
ctx["idempotency_hit"] = True
|
|
155
175
|
await run_middlewares(self._middlewares, "after", payload, record, context, ctx, None)
|
|
@@ -159,47 +179,66 @@ class FastSQS:
|
|
|
159
179
|
handled = False
|
|
160
180
|
|
|
161
181
|
message_type = payload.get(self.message_type_key)
|
|
182
|
+
self._log("debug", f"Message type", msg_id=msg_id, message_type=message_type)
|
|
183
|
+
|
|
162
184
|
if message_type:
|
|
163
185
|
route = self._find_route(message_type)
|
|
164
186
|
if route:
|
|
165
187
|
event_model, handler = route
|
|
188
|
+
self._log("debug", f"Found route for {message_type}", msg_id=msg_id, handler=handler.__name__)
|
|
166
189
|
|
|
167
190
|
try:
|
|
168
191
|
event_instance = event_model.model_validate(payload)
|
|
192
|
+
self._log("debug", f"Payload validation successful", msg_id=msg_id)
|
|
169
193
|
except ValidationError as e:
|
|
194
|
+
self._log("error", f"Validation failed", msg_id=msg_id, error=str(e))
|
|
170
195
|
raise InvalidMessage(f"Validation failed for {message_type}: {e}")
|
|
171
196
|
|
|
172
197
|
ctx["message_type"] = message_type
|
|
198
|
+
self._log("debug", f"Invoking handler", msg_id=msg_id, handler=handler.__name__)
|
|
173
199
|
result = await invoke_handler(handler, msg=event_instance, record=record, context=context, ctx=ctx)
|
|
200
|
+
self._log("debug", f"Handler completed successfully", msg_id=msg_id, result_type=type(result).__name__)
|
|
174
201
|
ctx["handler_result"] = result
|
|
175
202
|
handled = True
|
|
203
|
+
else:
|
|
204
|
+
self._log("warning", f"No route found for message type", msg_id=msg_id, message_type=message_type)
|
|
176
205
|
|
|
177
206
|
if not handled and self._routers:
|
|
178
|
-
|
|
207
|
+
self._log("debug", f"Trying routers", msg_id=msg_id, router_count=len(self._routers))
|
|
208
|
+
for i, router in enumerate(self._routers):
|
|
209
|
+
self._log("debug", f"Trying router {i}", msg_id=msg_id, router_key=router.key)
|
|
179
210
|
if await router.dispatch(payload, record, context, ctx, root_payload=payload):
|
|
211
|
+
self._log("debug", f"Router {i} handled the message", msg_id=msg_id)
|
|
180
212
|
handled = True
|
|
181
213
|
result = ctx.get("handler_result")
|
|
182
214
|
break
|
|
215
|
+
else:
|
|
216
|
+
self._log("debug", f"Router {i} did not handle the message", msg_id=msg_id)
|
|
183
217
|
|
|
184
218
|
if not handled:
|
|
185
219
|
if self._default_handler:
|
|
220
|
+
self._log("debug", f"Using default handler", msg_id=msg_id)
|
|
186
221
|
result = await invoke_handler(self._default_handler, payload=payload, record=record, context=context, ctx=ctx)
|
|
187
222
|
ctx["handler_result"] = result
|
|
188
223
|
else:
|
|
189
224
|
available_routes = list(self._routes.keys())
|
|
190
225
|
available_routers = [r.key for r in self._routers]
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
226
|
+
error_msg = (f"No handler found for message. "
|
|
227
|
+
f"Available FastSQS routes: {available_routes}, "
|
|
228
|
+
f"Available router keys: {available_routers}")
|
|
229
|
+
self._log("error", error_msg, msg_id=msg_id, available_routes=available_routes, available_routers=available_routers)
|
|
230
|
+
raise RouteNotFound(error_msg)
|
|
196
231
|
|
|
197
232
|
except Exception as e:
|
|
233
|
+
self._log("error", f"Handler error", msg_id=msg_id, error_type=type(e).__name__, error=str(e))
|
|
198
234
|
err = e
|
|
199
235
|
raise
|
|
200
236
|
finally:
|
|
237
|
+
self._log("debug", f"Running 'after' middleware chain", msg_id=msg_id)
|
|
201
238
|
await run_middlewares(self._middlewares, "after", payload, record, context, ctx, err)
|
|
239
|
+
self._log("debug", f"'after' middleware chain completed", msg_id=msg_id)
|
|
202
240
|
|
|
241
|
+
self._log("info", f"Record processing completed successfully", msg_id=msg_id)
|
|
203
242
|
return result
|
|
204
243
|
|
|
205
244
|
async def _handle_event(self, event: dict, context: Any) -> dict:
|
|
@@ -209,7 +248,7 @@ class FastSQS:
|
|
|
209
248
|
|
|
210
249
|
if self.debug:
|
|
211
250
|
queue_info = f"queue_type={self.queue_type.value}, records={len(records)}"
|
|
212
|
-
|
|
251
|
+
self._log("info", f"Processing event", queue_info=queue_info)
|
|
213
252
|
|
|
214
253
|
if self.is_fifo_queue():
|
|
215
254
|
return await self._handle_fifo_event(records, context)
|
|
@@ -219,6 +258,8 @@ class FastSQS:
|
|
|
219
258
|
async def _handle_standard_event(self, records: List[dict], context: Any) -> dict:
|
|
220
259
|
failures: List[Dict[str, str]] = []
|
|
221
260
|
|
|
261
|
+
self._log("info", f"Processing records in standard queue mode", record_count=len(records))
|
|
262
|
+
|
|
222
263
|
semaphore = asyncio.Semaphore(self.max_concurrent_messages)
|
|
223
264
|
|
|
224
265
|
async def process_with_semaphore(record):
|
|
@@ -231,11 +272,16 @@ class FastSQS:
|
|
|
231
272
|
for i, result in enumerate(results):
|
|
232
273
|
if isinstance(result, Exception):
|
|
233
274
|
msg_id = records[i].get("messageId", "UNKNOWN")
|
|
275
|
+
self._log("error", f"Record failed", msg_id=msg_id, error_type=type(result).__name__, error=str(result))
|
|
234
276
|
if self.debug:
|
|
235
|
-
|
|
277
|
+
self._log("debug", f"Record failed", msg_id=msg_id, error=str(result))
|
|
236
278
|
if self.enable_partial_batch_failure:
|
|
237
279
|
failures.append({"itemIdentifier": msg_id})
|
|
280
|
+
else:
|
|
281
|
+
msg_id = records[i].get("messageId", "UNKNOWN")
|
|
282
|
+
self._log("debug", f"Record succeeded", msg_id=msg_id)
|
|
238
283
|
|
|
284
|
+
self._log("info", f"Batch processing completed", succeeded=len(records) - len(failures), failed=len(failures))
|
|
239
285
|
return {"batchItemFailures": failures}
|
|
240
286
|
|
|
241
287
|
async def _handle_fifo_event(self, records: List[dict], context: Any) -> dict:
|
|
@@ -244,12 +290,12 @@ class FastSQS:
|
|
|
244
290
|
message_groups = group_records_by_message_group(records)
|
|
245
291
|
|
|
246
292
|
if self.debug:
|
|
247
|
-
|
|
293
|
+
self._log("info", f"FIFO processing", record_count=len(records), group_count=len(message_groups))
|
|
248
294
|
|
|
249
295
|
async def process_group(group_id: str, group_records: List[dict]):
|
|
250
296
|
group_failures = []
|
|
251
297
|
if self.debug:
|
|
252
|
-
|
|
298
|
+
self._log("debug", f"Processing group", group_id=group_id, record_count=len(group_records))
|
|
253
299
|
|
|
254
300
|
for rec in group_records:
|
|
255
301
|
try:
|
|
@@ -257,7 +303,7 @@ class FastSQS:
|
|
|
257
303
|
except Exception as e:
|
|
258
304
|
msg_id = rec.get("messageId", "UNKNOWN")
|
|
259
305
|
if self.debug:
|
|
260
|
-
|
|
306
|
+
self._log("error", f"FIFO record failed", msg_id=msg_id, group_id=group_id, error=str(e))
|
|
261
307
|
if self.enable_partial_batch_failure:
|
|
262
308
|
group_failures.append({"itemIdentifier": msg_id})
|
|
263
309
|
|
|
@@ -275,12 +321,17 @@ class FastSQS:
|
|
|
275
321
|
failures.extend(result)
|
|
276
322
|
elif isinstance(result, Exception):
|
|
277
323
|
if self.debug:
|
|
278
|
-
|
|
324
|
+
self._log("error", f"Message group processing failed", error=str(result))
|
|
279
325
|
|
|
280
326
|
return {"batchItemFailures": failures}
|
|
281
327
|
|
|
282
328
|
async def _handle_record_safe(self, record: dict, context: Any) -> None:
|
|
283
|
-
|
|
329
|
+
msg_id = record.get("messageId", "UNKNOWN")
|
|
330
|
+
try:
|
|
331
|
+
await self._handle_record(record, context)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
self._log("error", f"Record processing failed", msg_id=msg_id, error_type=type(e).__name__, error=str(e))
|
|
334
|
+
raise
|
|
284
335
|
|
|
285
336
|
def handler(self, event: dict, context: Any) -> dict:
|
|
286
337
|
try:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ThreadPoolManager:
|
|
6
|
+
"""
|
|
7
|
+
Manager for a shared ThreadPoolExecutor with a hard cap on the
|
|
8
|
+
number of worker threads (max = 1024 for AWS Lambda safety).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
MAX_WORKERS: int = 1024
|
|
12
|
+
THREAD_NAME_PREFIX: str = "thread-pool-manager"
|
|
13
|
+
_instance = None
|
|
14
|
+
|
|
15
|
+
def __new__(cls):
|
|
16
|
+
if cls._instance is None:
|
|
17
|
+
cls._instance = super().__new__(cls)
|
|
18
|
+
cls._instance._configure_executor()
|
|
19
|
+
return cls._instance
|
|
20
|
+
|
|
21
|
+
def _configure_executor(self) -> None:
|
|
22
|
+
self._executor = ThreadPoolExecutor(
|
|
23
|
+
max_workers=self.MAX_WORKERS,
|
|
24
|
+
thread_name_prefix=self.THREAD_NAME_PREFIX,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _swallow(call: Callable[[], None]) -> None:
|
|
29
|
+
try:
|
|
30
|
+
call()
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def submit(self, fn: Callable, *args, **kwargs) -> None:
|
|
35
|
+
"""Submit a task to the shared executor (fire-and-forget), swallowing exceptions."""
|
|
36
|
+
self._executor.submit(self._swallow, lambda: fn(*args, **kwargs))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from .concurrency import ThreadPoolManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def background(func: Callable):
|
|
8
|
+
"""
|
|
9
|
+
Decorator to execute the function in the background using the shared thread pool.
|
|
10
|
+
Exceptions inside the function are swallowed by the manager. Returns None immediately.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
@background
|
|
14
|
+
def my_task(...):
|
|
15
|
+
...
|
|
16
|
+
"""
|
|
17
|
+
@functools.wraps(func)
|
|
18
|
+
def wrapper(*args, **kwargs):
|
|
19
|
+
ThreadPoolManager().submit(func, *args, **kwargs)
|
|
20
|
+
return wrapper
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def background(func: Callable) -> Callable:
|
|
7
|
+
if asyncio.iscoroutinefunction(func):
|
|
8
|
+
@functools.wraps(func)
|
|
9
|
+
async def async_wrapper(*args, **kwargs):
|
|
10
|
+
return await func(*args, **kwargs)
|
|
11
|
+
return async_wrapper
|
|
12
|
+
else:
|
|
13
|
+
@functools.wraps(func)
|
|
14
|
+
def sync_wrapper(*args, **kwargs):
|
|
15
|
+
return func(*args, **kwargs)
|
|
16
|
+
return sync_wrapper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .logger import Logger
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OtelConfig:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.use_otel = os.getenv("USE_OTEL", "true").lower() != "false"
|
|
7
|
+
self.environment = os.getenv("OTEL_ENVIRONMENT", "production")
|
|
8
|
+
self.service_name = os.getenv("OTEL_SERVICE_NAME", "fastsqs-service")
|
|
9
|
+
self.tracer_name = os.getenv("OTEL_TRACER_NAME", "fastsqs-tracer")
|
|
10
|
+
self.elastic_url = os.getenv("OTEL_ELASTIC_URL", None)
|
|
11
|
+
self.elastic_token = os.getenv("OTEL_ELASTIC_TOKEN", None)
|
|
12
|
+
self.exporter_endpoint = os.getenv("OTEL_EXPORTER_ENDPOINT", "http://localhost:4318/v1/traces")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from .config import OtelConfig
|
|
5
|
+
from .utils import get_elastic_client, build_log_entry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Environment(Enum):
|
|
9
|
+
PRODUCTION = "production"
|
|
10
|
+
HOMOLOG = "homolog"
|
|
11
|
+
LOCAL = "local"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
otel_config = OtelConfig()
|
|
15
|
+
OTEL_ENVIRONMENT = otel_config.environment
|
|
16
|
+
OTEL_SERVICE_NAME = otel_config.service_name
|
|
17
|
+
USE_OTEL = otel_config.use_otel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ElasticsearchHandler(logging.Handler):
|
|
21
|
+
ELASTICSEARCH_ENVIRONMENTS = {
|
|
22
|
+
Environment.PRODUCTION.value,
|
|
23
|
+
Environment.HOMOLOG.value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, index_name=None, use_otel=USE_OTEL):
|
|
27
|
+
super().__init__()
|
|
28
|
+
|
|
29
|
+
self.enable_logging = use_otel and OTEL_ENVIRONMENT in self.ELASTICSEARCH_ENVIRONMENTS
|
|
30
|
+
|
|
31
|
+
if self.enable_logging:
|
|
32
|
+
self.es_client = get_elastic_client()
|
|
33
|
+
self.index_name = (
|
|
34
|
+
index_name or
|
|
35
|
+
f"logs-{OTEL_SERVICE_NAME}-{OTEL_ENVIRONMENT}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def emit(self, record):
|
|
39
|
+
try:
|
|
40
|
+
if not self.enable_logging:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
log_entry = build_log_entry(
|
|
44
|
+
record=record,
|
|
45
|
+
service_name=OTEL_SERVICE_NAME,
|
|
46
|
+
environment=OTEL_ENVIRONMENT,
|
|
47
|
+
formatter=self
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if self.es_client:
|
|
51
|
+
self.es_client.index(index=self.index_name, document=log_entry)
|
|
52
|
+
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logging.getLogger(__name__).error(
|
|
55
|
+
f"Failed to send log to Elasticsearch: {e}"
|
|
56
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .config import OtelConfig
|
|
6
|
+
from ..concurrency.decorators import background
|
|
7
|
+
from .elasticsearch_handler import ElasticsearchHandler
|
|
8
|
+
|
|
9
|
+
otel_config = OtelConfig()
|
|
10
|
+
USE_OTEL = otel_config.use_otel
|
|
11
|
+
|
|
12
|
+
class Logger:
|
|
13
|
+
_instance = None
|
|
14
|
+
|
|
15
|
+
def __new__(cls):
|
|
16
|
+
if cls._instance is None:
|
|
17
|
+
cls._instance = super(Logger, cls).__new__(cls)
|
|
18
|
+
cls._instance._configure_logger()
|
|
19
|
+
return cls._instance
|
|
20
|
+
|
|
21
|
+
def _configure_logger(self):
|
|
22
|
+
logging.getLogger().setLevel(logging.WARNING)
|
|
23
|
+
self.use_otel = USE_OTEL
|
|
24
|
+
self._logger = logging.getLogger("appLogger")
|
|
25
|
+
self._logger.setLevel(logging.INFO)
|
|
26
|
+
self._logger.propagate = False
|
|
27
|
+
if not self._logger.handlers:
|
|
28
|
+
self._add_handler(logging.StreamHandler())
|
|
29
|
+
if USE_OTEL:
|
|
30
|
+
try:
|
|
31
|
+
self._add_handler(ElasticsearchHandler(use_otel=self.use_otel))
|
|
32
|
+
except Exception as e:
|
|
33
|
+
self._logger.warning(f"Failed to initialize ElasticsearchHandler: {e}")
|
|
34
|
+
|
|
35
|
+
def _add_handler(self, handler):
|
|
36
|
+
try:
|
|
37
|
+
formatter = logging.Formatter("%(message)s")
|
|
38
|
+
handler.setFormatter(formatter)
|
|
39
|
+
self._logger.addHandler(handler)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
self._logger.error(
|
|
42
|
+
f"Failed to configure {handler.__class__.__name__}: {e}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@background
|
|
46
|
+
def log(self, level, message, **data):
|
|
47
|
+
log_method = getattr(self._logger, level, self._logger.debug)
|
|
48
|
+
if data:
|
|
49
|
+
try:
|
|
50
|
+
message = f"{message} | data: {json.dumps(data, default=str)}"
|
|
51
|
+
except Exception:
|
|
52
|
+
message = f"{message} | data: {data}"
|
|
53
|
+
log_method(message)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from elasticsearch import Elasticsearch
|
|
3
|
+
from .config import OtelConfig
|
|
4
|
+
|
|
5
|
+
otel_config = OtelConfig()
|
|
6
|
+
ELASTIC_URL = otel_config.elastic_url
|
|
7
|
+
ELASTIC_TOKEN = otel_config.elastic_token
|
|
8
|
+
|
|
9
|
+
_es_client = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def filter_extra_fields(record_dict):
|
|
13
|
+
allowed_fields = {"data"}
|
|
14
|
+
return {key: value for key, value in record_dict.items() if key in allowed_fields}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_elastic_client():
|
|
18
|
+
global _es_client
|
|
19
|
+
if _es_client is None:
|
|
20
|
+
_es_client = Elasticsearch(
|
|
21
|
+
ELASTIC_URL,
|
|
22
|
+
api_key=ELASTIC_TOKEN,
|
|
23
|
+
request_timeout=10
|
|
24
|
+
)
|
|
25
|
+
return _es_client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_log_entry(record, request_id, service_name, environment, formatter):
|
|
29
|
+
return {
|
|
30
|
+
"@timestamp": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
"log.level": record.levelname.lower(),
|
|
32
|
+
"message": formatter.format(record),
|
|
33
|
+
"app.name": service_name,
|
|
34
|
+
"x-request-id": request_id,
|
|
35
|
+
"service.name": service_name,
|
|
36
|
+
"environment": environment,
|
|
37
|
+
"extra": filter_extra_fields(record.__dict__),
|
|
38
|
+
"agent": {
|
|
39
|
+
"name": "opentelemetry/python",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
@@ -5,6 +5,15 @@ from typing import Any, Awaitable, List, Optional
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Middleware:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._app = None # Will be set by FastSQS when middleware is added
|
|
10
|
+
|
|
11
|
+
def _log(self, level: str, message: str, **data) -> None:
|
|
12
|
+
"""Log method that routes through the app's logging system"""
|
|
13
|
+
if self._app and hasattr(self._app, '_log'):
|
|
14
|
+
self._app._log(level, message, **data)
|
|
15
|
+
# If no app or logging available, silently do nothing
|
|
16
|
+
|
|
8
17
|
async def before(self, payload: dict, record: dict, context: Any, ctx: dict) -> None:
|
|
9
18
|
return None
|
|
10
19
|
|