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.
Files changed (41) hide show
  1. {fastsqs-0.3.2/fastsqs.egg-info → fastsqs-0.3.4}/PKG-INFO +13 -1
  2. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/app.py +67 -16
  3. fastsqs-0.3.4/fastsqs/concurrency/__init__.py +2 -0
  4. fastsqs-0.3.4/fastsqs/concurrency/concurrency.py +36 -0
  5. fastsqs-0.3.4/fastsqs/concurrency/decorators.py +20 -0
  6. fastsqs-0.3.4/fastsqs/concurrency.py +16 -0
  7. fastsqs-0.3.4/fastsqs/logger/__init__.py +1 -0
  8. fastsqs-0.3.4/fastsqs/logger/config.py +12 -0
  9. fastsqs-0.3.4/fastsqs/logger/elasticsearch_handler.py +56 -0
  10. fastsqs-0.3.4/fastsqs/logger/logger.py +53 -0
  11. fastsqs-0.3.4/fastsqs/logger/utils.py +41 -0
  12. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/base.py +9 -0
  13. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/error_handling.py +71 -7
  14. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/idempotency.py +41 -2
  15. fastsqs-0.3.4/fastsqs/middleware/logging.py +140 -0
  16. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/parallelization.py +17 -0
  17. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/timing.py +10 -1
  18. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/visibility.py +23 -14
  19. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/presets.py +10 -2
  20. {fastsqs-0.3.2 → fastsqs-0.3.4/fastsqs.egg-info}/PKG-INFO +13 -1
  21. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/SOURCES.txt +9 -0
  22. fastsqs-0.3.4/fastsqs.egg-info/requires.txt +23 -0
  23. {fastsqs-0.3.2 → fastsqs-0.3.4}/setup.py +16 -2
  24. fastsqs-0.3.2/fastsqs/middleware/logging.py +0 -63
  25. fastsqs-0.3.2/fastsqs.egg-info/requires.txt +0 -9
  26. {fastsqs-0.3.2 → fastsqs-0.3.4}/LICENSE +0 -0
  27. {fastsqs-0.3.2 → fastsqs-0.3.4}/MANIFEST.in +0 -0
  28. {fastsqs-0.3.2 → fastsqs-0.3.4}/README.md +0 -0
  29. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/__init__.py +0 -0
  30. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/events.py +0 -0
  31. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/exceptions.py +0 -0
  32. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/middleware/__init__.py +0 -0
  33. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/__init__.py +0 -0
  34. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/entry.py +0 -0
  35. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/routing/router.py +0 -0
  36. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/types.py +0 -0
  37. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs/utils.py +0 -0
  38. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/dependency_links.txt +0 -0
  39. {fastsqs-0.3.2 → fastsqs-0.3.4}/fastsqs.egg-info/top_level.txt +0 -0
  40. {fastsqs-0.3.2 → fastsqs-0.3.4}/pyproject.toml +0 -0
  41. {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.2
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
- print(f"Warning: Message type variant '{variant}' conflicts with existing route")
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
- print(f"[FastSQS] Queue type set to: {queue_type.value}")
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
- print(f"[FastSQS] Idempotency hit for message {msg_id}: {idempotency_hit.key}")
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
- for router in self._routers:
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
- raise RouteNotFound(
192
- f"No handler found for message. "
193
- f"Available FastSQS routes: {available_routes}, "
194
- f"Available router keys: {available_routers}"
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
- print(f"[FastSQS] Processing event: {queue_info}")
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
- print(f"[FastSQS] Record failed: messageId={msg_id}, error={result}")
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
- print(f"[FastSQS] FIFO processing: {len(records)} records in {len(message_groups)} groups")
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
- print(f"[FastSQS] Processing group '{group_id}' with {len(group_records)} records")
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
- print(f"[FastSQS] FIFO record failed: messageId={msg_id}, group={group_id}, error={e}")
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
- print(f"[FastSQS] Message group processing failed: {result}")
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
- await self._handle_record(record, context)
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,2 @@
1
+ from .concurrency import ThreadPoolManager
2
+ from .decorators import background
@@ -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