jararaca 0.2.37a12__py3-none-any.whl → 0.4.0a5__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.
Files changed (91) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +267 -15
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +106 -0
  5. jararaca/broker_backend/mapper.py +25 -0
  6. jararaca/broker_backend/redis_broker_backend.py +168 -0
  7. jararaca/cli.py +840 -103
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +55 -16
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +5 -1
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +90 -85
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
  23. jararaca/messagebus/message.py +31 -0
  24. jararaca/messagebus/publisher.py +47 -4
  25. jararaca/messagebus/worker.py +1615 -135
  26. jararaca/microservice.py +248 -36
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +177 -16
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +8 -2
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +213 -18
  34. jararaca/persistence/base.py +40 -3
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +74 -32
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +170 -82
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +120 -41
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +34 -4
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +6 -2
  55. jararaca/presentation/websocket/websocket_interceptor.py +74 -23
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +81 -0
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +76 -0
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +758 -0
  69. jararaca/scheduler/decorators.py +89 -28
  70. jararaca/scheduler/types.py +11 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +10 -4
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1126 -189
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +372 -0
  79. jararaca/utils/retry.py +148 -0
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca/messagebus/types.py +0 -30
  87. jararaca/scheduler/scheduler.py +0 -154
  88. jararaca/tools/metadata.py +0 -47
  89. jararaca-0.2.37a12.dist-info/RECORD +0 -63
  90. /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
  91. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
jararaca/microservice.py CHANGED
@@ -1,4 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import asyncio
1
6
  import inspect
7
+ import logging
2
8
  from contextlib import contextmanager, suppress
3
9
  from contextvars import ContextVar
4
10
  from dataclasses import dataclass, field
@@ -9,6 +15,7 @@ from typing import (
9
15
  Any,
10
16
  AsyncContextManager,
11
17
  Callable,
18
+ Coroutine,
12
19
  Generator,
13
20
  Literal,
14
21
  Protocol,
@@ -18,69 +25,115 @@ from typing import (
18
25
  runtime_checkable,
19
26
  )
20
27
 
21
- from fastapi import Request, WebSocket
28
+ from fastapi import Request, Response, WebSocket
22
29
 
23
30
  from jararaca.core.providers import ProviderSpec, T, Token
24
31
  from jararaca.messagebus import MessageOf
25
- from jararaca.messagebus.types import Message
32
+ from jararaca.messagebus.message import Message
33
+ from jararaca.reflect.controller_inspect import ControllerMemberReflect
34
+
35
+ logger = logging.getLogger(__name__)
26
36
 
27
37
  if TYPE_CHECKING:
28
38
  from typing_extensions import TypeIs
29
39
 
30
40
 
31
41
  @dataclass
32
- class SchedulerAppContext:
42
+ class SchedulerTransactionData:
43
+ task_name: str
33
44
  triggered_at: datetime
34
45
  scheduled_to: datetime
35
46
  cron_expression: str
36
- action: Callable[..., Any]
37
47
  context_type: Literal["scheduler"] = "scheduler"
38
48
 
39
49
 
40
50
  @dataclass
41
- class HttpAppContext:
51
+ class HttpTransactionData:
42
52
  request: Request
53
+ response: Response
43
54
  context_type: Literal["http"] = "http"
44
55
 
45
56
 
46
57
  @dataclass
47
- class MessageBusAppContext:
58
+ class MessageBusTransactionData:
48
59
  topic: str
49
60
  message: MessageOf[Message]
61
+ message_type: Type[Message]
50
62
  context_type: Literal["message_bus"] = "message_bus"
51
63
 
52
64
 
53
65
  @dataclass
54
- class WebSocketAppContext:
66
+ class WebSocketTransactionData:
55
67
  websocket: WebSocket
56
68
  context_type: Literal["websocket"] = "websocket"
57
69
 
58
70
 
59
- AppContext = (
60
- MessageBusAppContext | HttpAppContext | SchedulerAppContext | WebSocketAppContext
71
+ APP_TYPE = Literal["http", "worker", "beat"]
72
+
73
+ TransactionData = (
74
+ MessageBusTransactionData
75
+ | HttpTransactionData
76
+ | SchedulerTransactionData
77
+ | WebSocketTransactionData
61
78
  )
62
79
 
63
- app_context_ctxvar = ContextVar[AppContext]("app_context")
80
+
81
+ @dataclass
82
+ class AppTransactionContext:
83
+ transaction_data: TransactionData
84
+ controller_member_reflect: ControllerMemberReflect
85
+
86
+
87
+ AppContext = AppTransactionContext
88
+ """
89
+ Alias for AppTransactionContext, used for compatibility with existing code.
90
+ """
91
+
92
+
93
+ app_transaction_context_var = ContextVar[AppTransactionContext]("app_context")
64
94
 
65
95
 
66
- def use_app_context() -> AppContext:
67
- return app_context_ctxvar.get()
96
+ def use_app_transaction_context() -> AppTransactionContext:
97
+ """
98
+ Returns the current application transaction context.
99
+ This function is used to access the application transaction context in the context of an application transaction.
100
+ If no context is set, it raises a LookupError.
101
+ """
102
+
103
+ return app_transaction_context_var.get()
104
+
105
+
106
+ def use_app_tx_ctx_data() -> TransactionData:
107
+ """
108
+ Returns the transaction data from the current app transaction context.
109
+ This function is used to access the transaction data in the context of an application transaction.
110
+ """
111
+
112
+ return use_app_transaction_context().transaction_data
113
+
114
+
115
+ use_app_context = use_app_tx_ctx_data
116
+ """Alias for use_app_tx_ctx_data, used for compatibility with existing code."""
68
117
 
69
118
 
70
119
  @contextmanager
71
- def provide_app_context(app_context: AppContext) -> Generator[None, None, None]:
72
- token = app_context_ctxvar.set(app_context)
120
+ def provide_app_context(
121
+ app_context: AppTransactionContext,
122
+ ) -> Generator[None, None, None]:
123
+ token = app_transaction_context_var.set(app_context)
73
124
  try:
74
125
  yield
75
126
  finally:
76
127
  with suppress(ValueError):
77
- app_context_ctxvar.reset(token)
128
+ app_transaction_context_var.reset(token)
78
129
 
79
130
 
80
131
  @runtime_checkable
81
132
  class AppInterceptor(Protocol):
82
133
 
83
- def intercept(self, app_context: AppContext) -> AsyncContextManager[None]: ...
134
+ def intercept(
135
+ self, app_context: AppTransactionContext
136
+ ) -> AsyncContextManager[None]: ...
84
137
 
85
138
 
86
139
  class AppInterceptorWithLifecycle(Protocol):
@@ -106,6 +159,49 @@ class Microservice:
106
159
  )
107
160
 
108
161
 
162
+ @dataclass
163
+ class InstantiationNode:
164
+ property_name: str
165
+ parent: "InstantiationNode | None" = None
166
+ source_type: Any | None = None
167
+ target_type: Any | None = None
168
+
169
+
170
+ instantiation_vector_ctxvar = ContextVar[list[InstantiationNode]](
171
+ "instantiation_vector", default=[]
172
+ )
173
+
174
+
175
+ def print_instantiation_vector(
176
+ instantiation_vector: list[InstantiationNode],
177
+ ) -> None:
178
+ """
179
+ Prints the instantiation vector for debugging purposes.
180
+ """
181
+ for node in instantiation_vector:
182
+ print(
183
+ f"Property: {node.property_name}, Source: {node.source_type}, Target: {node.target_type}"
184
+ )
185
+
186
+
187
+ @contextmanager
188
+ def span_instantiation_vector(
189
+ instantiation_node: InstantiationNode,
190
+ ) -> Generator[None, None, None]:
191
+ """
192
+ Context manager to track instantiation nodes in a vector.
193
+ This is useful for debugging and tracing instantiation paths.
194
+ """
195
+ current_vector = list(instantiation_vector_ctxvar.get())
196
+ current_vector.append(instantiation_node)
197
+ token = instantiation_vector_ctxvar.set(current_vector)
198
+ try:
199
+ yield
200
+ finally:
201
+ with suppress(ValueError):
202
+ instantiation_vector_ctxvar.reset(token)
203
+
204
+
109
205
  class Container:
110
206
 
111
207
  def __init__(self, app: Microservice) -> None:
@@ -122,40 +218,54 @@ class Container:
122
218
  if provider.use_value:
123
219
  self.instances_map[provider.provide] = provider.use_value
124
220
  elif provider.use_class:
125
- self.get_and_register(provider.use_class, provider.provide)
221
+ self._get_and_register(provider.use_class, provider.provide)
126
222
  elif provider.use_factory:
127
- self.get_and_register(provider.use_factory, provider.provide)
223
+ self._get_and_register(provider.use_factory, provider.provide)
128
224
  else:
129
- self.get_and_register(provider, provider)
225
+ self._get_and_register(provider, provider)
130
226
 
131
- def instantiate(self, type_: type[Any] | Callable[..., Any]) -> Any:
227
+ def _instantiate(self, type_: type[Any] | Callable[..., Any]) -> Any:
132
228
 
133
- dependencies = self.parse_dependencies(type_)
229
+ dependencies = self._parse_dependencies(type_)
134
230
 
135
- evaluated_dependencies = {
136
- name: self.get_or_register_token_or_type(dependency)
137
- for name, dependency in dependencies.items()
138
- }
231
+ evaluated_dependencies: dict[str, Any] = {}
232
+ for name, dependency in dependencies.items():
233
+ with span_instantiation_vector(
234
+ InstantiationNode(
235
+ property_name=name,
236
+ source_type=type_,
237
+ target_type=dependency,
238
+ )
239
+ ):
240
+ evaluated_dependencies[name] = self.get_or_register_token_or_type(
241
+ dependency
242
+ )
139
243
 
140
244
  instance = type_(**evaluated_dependencies)
141
245
 
142
246
  return instance
143
247
 
144
- def parse_dependencies(
248
+ def _parse_dependencies(
145
249
  self, provider: type[Any] | Callable[..., Any]
146
250
  ) -> dict[str, type[Any]]:
147
251
 
148
- signature = inspect.signature(provider)
252
+ vector = instantiation_vector_ctxvar.get()
253
+ try:
254
+ signature = inspect.signature(provider)
255
+ except ValueError:
256
+ print("VECTOR:", vector)
257
+ print_instantiation_vector(vector)
258
+ raise
149
259
 
150
260
  parameters = signature.parameters
151
261
 
152
262
  return {
153
- name: self.lookup_parameter_type(parameter)
263
+ name: self._lookup_parameter_type(parameter)
154
264
  for name, parameter in parameters.items()
155
265
  if parameter.annotation != inspect.Parameter.empty
156
266
  }
157
267
 
158
- def lookup_parameter_type(self, parameter: inspect.Parameter) -> Any:
268
+ def _lookup_parameter_type(self, parameter: inspect.Parameter) -> Any:
159
269
  if parameter.annotation == inspect.Parameter.empty:
160
270
  raise Exception(f"Parameter {parameter.name} has no type annotation")
161
271
 
@@ -188,14 +298,14 @@ class Container:
188
298
  item_type = bind_to = token_or_type
189
299
 
190
300
  if token_or_type not in self.instances_map:
191
- return self.get_and_register(item_type, bind_to)
301
+ return self._get_and_register(item_type, bind_to)
192
302
 
193
303
  return cast(T, self.instances_map[bind_to])
194
304
 
195
- def get_and_register(
305
+ def _get_and_register(
196
306
  self, item_type: Type[T] | Callable[..., T], bind_to: Any
197
307
  ) -> T:
198
- instance = self.instantiate(item_type)
308
+ instance = self._instantiate(item_type)
199
309
  self.register(instance, bind_to)
200
310
  return cast(T, instance)
201
311
 
@@ -226,18 +336,120 @@ def provide_container(container: Container) -> Generator[None, None, None]:
226
336
  current_container_ctx.reset(token)
227
337
 
228
338
 
339
+ class ShutdownState(Protocol):
340
+
341
+ def request_shutdown(self) -> None: ...
342
+
343
+ def is_shutdown_requested(self) -> bool: ...
344
+
345
+ async def wait_for_shutdown(self) -> None: ...
346
+
347
+
348
+ shutdown_state_ctx = ContextVar[ShutdownState]("shutdown_state")
349
+
350
+
351
+ def is_shutting_down() -> bool:
352
+ """
353
+ Check if the application is in the process of shutting down.
354
+ """
355
+ return shutdown_state_ctx.get().is_shutdown_requested()
356
+
357
+
358
+ def request_shutdown() -> None:
359
+ """
360
+ Request the application to shut down.
361
+ This will set the shutdown event, allowing the application to gracefully shut down.
362
+ """
363
+ shutdown_state_ctx.get().request_shutdown()
364
+
365
+
366
+ async def wait_for_shutdown() -> None:
367
+ """
368
+ Wait for the shutdown event to be set.
369
+ This function will block until a shutdown is requested.
370
+ """
371
+ await shutdown_state_ctx.get().wait_for_shutdown()
372
+
373
+
374
+ async def shutdown_race(*concurrent_tasks: Coroutine[Any, Any, Any]) -> bool:
375
+ """
376
+ Wait for either a shutdown request or any of the provided tasks to complete.
377
+ This function will return as soon as a shutdown is requested or any task finishes.
378
+ Returns True if shutdown was requested, False if a task completed first.
379
+ """
380
+
381
+ tasks = [asyncio.create_task(t) for t in concurrent_tasks + (wait_for_shutdown(),)]
382
+
383
+ _, pending = await asyncio.wait(
384
+ tasks,
385
+ return_when=asyncio.FIRST_COMPLETED,
386
+ )
387
+
388
+ for task in pending:
389
+ task.cancel()
390
+
391
+ return is_shutting_down()
392
+
393
+
394
+ @contextmanager
395
+ def provide_shutdown_state(
396
+ state: ShutdownState,
397
+ ) -> Generator[None, None, None]:
398
+ """
399
+ Context manager to provide the shutdown state.
400
+ This is used to manage the shutdown event for the application.
401
+ """
402
+
403
+ token = shutdown_state_ctx.set(state)
404
+ try:
405
+ yield
406
+ finally:
407
+ with suppress(ValueError):
408
+ shutdown_state_ctx.reset(token)
409
+
410
+
411
+ app_type_ctx = ContextVar[APP_TYPE]("app_type")
412
+
413
+
414
+ def use_app_type() -> APP_TYPE:
415
+ """
416
+ Returns the current application type.
417
+ This function is used to access the application type in the context of an application transaction.
418
+ If no context is set, it raises a LookupError.
419
+ """
420
+ return app_type_ctx.get()
421
+
422
+
423
+ @contextmanager
424
+ def providing_app_type(app_type: APP_TYPE) -> Generator[None, None, None]:
425
+ """
426
+ Context manager to provide the application type.
427
+ This is used to set the application type for the current transaction.
428
+ """
429
+ token = app_type_ctx.set(app_type)
430
+ try:
431
+ yield
432
+ finally:
433
+ with suppress(ValueError):
434
+ app_type_ctx.reset(token)
435
+
436
+
229
437
  __all__ = [
230
- "AppContext",
438
+ "AppTransactionContext",
231
439
  "AppInterceptor",
232
440
  "AppInterceptorWithLifecycle",
233
441
  "Container",
234
442
  "Microservice",
235
- "SchedulerAppContext",
236
- "WebSocketAppContext",
237
- "app_context_ctxvar",
443
+ "SchedulerTransactionData",
444
+ "WebSocketTransactionData",
445
+ "app_transaction_context_var",
238
446
  "current_container_ctx",
239
447
  "provide_app_context",
240
448
  "provide_container",
241
449
  "use_app_context",
242
450
  "use_current_container",
451
+ "HttpTransactionData",
452
+ "MessageBusTransactionData",
453
+ "is_interceptor_with_lifecycle",
454
+ "AppContext",
243
455
  ]
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ TRACEPARENT_KEY = "traceparent"
6
+
7
+ __all__ = ["TRACEPARENT_KEY"]
@@ -1,3 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import inspect
1
6
  from contextlib import contextmanager, suppress
2
7
  from contextvars import ContextVar
3
8
  from functools import wraps
@@ -8,29 +13,82 @@ from typing import (
8
13
  Callable,
9
14
  ContextManager,
10
15
  Generator,
11
- ParamSpec,
16
+ Literal,
17
+ Mapping,
12
18
  Protocol,
19
+ Sequence,
13
20
  TypeVar,
21
+ Union,
14
22
  )
15
23
 
16
- from jararaca.microservice import AppContext
24
+ from jararaca.microservice import AppTransactionContext
25
+
26
+ F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
27
+
28
+ AttributeValue = Union[
29
+ str,
30
+ bool,
31
+ int,
32
+ float,
33
+ Sequence[str],
34
+ Sequence[bool],
35
+ Sequence[int],
36
+ Sequence[float],
37
+ ]
38
+
39
+ AttributeMap = Mapping[str, AttributeValue]
17
40
 
18
- P = ParamSpec("P")
19
- R = TypeVar("R")
41
+
42
+ class TracingSpan(Protocol): ...
43
+
44
+
45
+ class TracingSpanContext(Protocol): ...
20
46
 
21
47
 
22
48
  class TracingContextProvider(Protocol):
23
49
 
24
- def __call__(
25
- self, trace_name: str, context_attributes: dict[str, str]
50
+ def start_span_context(
51
+ self, trace_name: str, context_attributes: AttributeMap | None
26
52
  ) -> ContextManager[Any]: ...
27
53
 
54
+ def add_event(
55
+ self,
56
+ event_name: str,
57
+ event_attributes: AttributeMap | None = None,
58
+ ) -> None: ...
59
+
60
+ def set_span_status(self, status_code: Literal["OK", "ERROR", "UNSET"]) -> None: ...
61
+
62
+ def record_exception(
63
+ self,
64
+ exception: Exception,
65
+ attributes: AttributeMap | None = None,
66
+ escaped: bool = False,
67
+ ) -> None: ...
68
+
69
+ def set_span_attribute(
70
+ self,
71
+ key: str,
72
+ value: AttributeValue,
73
+ ) -> None: ...
74
+
75
+ def update_span_name(self, new_name: str) -> None: ...
76
+
77
+ def add_link(self, span_context: TracingSpanContext) -> None: ...
78
+
79
+ def get_current_span(self) -> TracingSpan | None: ...
80
+ def get_current_span_context(self) -> TracingSpanContext | None: ...
81
+
28
82
 
29
83
  class TracingContextProviderFactory(Protocol):
30
84
 
31
- def root_setup(self, app_context: AppContext) -> AsyncContextManager[None]: ...
85
+ def root_setup(
86
+ self, app_context: AppTransactionContext
87
+ ) -> AsyncContextManager[None]: ...
32
88
 
33
- def provide_provider(self, app_context: AppContext) -> TracingContextProvider: ...
89
+ def provide_provider(
90
+ self, app_context: AppTransactionContext
91
+ ) -> TracingContextProvider: ...
34
92
 
35
93
 
36
94
  tracing_ctx_provider_ctxv = ContextVar[TracingContextProvider]("tracing_ctx_provider")
@@ -72,22 +130,125 @@ class TracedFunc:
72
130
 
73
131
  def __call__(
74
132
  self,
75
- decorated: Callable[P, Awaitable[R]],
76
- ) -> Callable[P, Awaitable[R]]:
133
+ decorated: F,
134
+ ) -> F:
77
135
 
78
136
  @wraps(decorated)
79
137
  async def wrapper(
80
- *args: P.args,
81
- **kwargs: P.kwargs,
82
- ) -> R:
138
+ *args: Any,
139
+ **kwargs: Any,
140
+ ) -> Any:
83
141
 
84
142
  if ctx_provider := get_tracing_ctx_provider():
85
- with ctx_provider(
143
+ with ctx_provider.start_span_context(
86
144
  self.trace_name,
87
- self.trace_mapper(**kwargs),
145
+ self.trace_mapper(*args, **kwargs),
88
146
  ):
89
147
  return await decorated(*args, **kwargs)
90
148
 
91
149
  return await decorated(*args, **kwargs)
92
150
 
93
- return wrapper
151
+ return wrapper # type: ignore[return-value]
152
+
153
+
154
+ C = TypeVar("C", bound=type)
155
+
156
+
157
+ class TracedClass:
158
+ """
159
+ Class decorator that automatically applies tracing to all async methods in a class.
160
+
161
+ Usage:
162
+ @TracedClass()
163
+ class MyService:
164
+ async def method1(self) -> str:
165
+ return "hello"
166
+
167
+ async def method2(self, x: int) -> int:
168
+ return x * 2
169
+
170
+ def sync_method(self) -> str: # Not traced
171
+ return "sync"
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ trace_name_prefix: str | None = None,
177
+ trace_mapper: Callable[..., dict[str, str]] = default_trace_mapper,
178
+ include_private: bool = False,
179
+ exclude_methods: set[str] | None = None,
180
+ ):
181
+ """
182
+ Initialize the TracedClass decorator.
183
+
184
+ Args:
185
+ trace_name_prefix: Prefix for trace names. If None, uses class name.
186
+ trace_mapper: Function to map method arguments to trace attributes.
187
+ include_private: Whether to trace private methods (starting with _).
188
+ exclude_methods: Set of method names to exclude from tracing.
189
+ """
190
+ self.trace_name_prefix = trace_name_prefix
191
+ self.trace_mapper = trace_mapper
192
+ self.include_private = include_private
193
+ self.exclude_methods = exclude_methods or set()
194
+
195
+ def __call__(self, cls: C) -> C:
196
+ """Apply tracing to all async methods in the class."""
197
+
198
+ # Use class name as prefix if not provided
199
+ trace_prefix = self.trace_name_prefix or cls.__name__
200
+
201
+ # Get all methods in the class
202
+ for name, method in inspect.getmembers_static(
203
+ cls, predicate=inspect.isfunction
204
+ ):
205
+ # Skip if method should be excluded
206
+ if name in self.exclude_methods:
207
+ continue
208
+
209
+ # Skip private methods unless explicitly included
210
+ if name.startswith("_") and not self.include_private:
211
+ continue
212
+
213
+ # Only trace async methods
214
+ if inspect.iscoroutinefunction(method):
215
+ trace_name = f"{trace_prefix}.{name}"
216
+ traced_method = TracedFunc(trace_name, self.trace_mapper)(method)
217
+ setattr(cls, name, traced_method)
218
+
219
+ return cls
220
+
221
+
222
+ def traced_class(
223
+ trace_name_prefix: str | None = None,
224
+ trace_mapper: Callable[..., dict[str, str]] = default_trace_mapper,
225
+ include_private: bool = False,
226
+ exclude_methods: set[str] | None = None,
227
+ ) -> Callable[[C], C]:
228
+ """
229
+ Functional interface for TracedClass decorator.
230
+
231
+ Usage:
232
+ @traced_class(trace_name_prefix="MyService")
233
+ class MyService:
234
+ async def method1(self) -> str:
235
+ return "hello"
236
+ """
237
+ return TracedClass(
238
+ trace_name_prefix=trace_name_prefix,
239
+ trace_mapper=trace_mapper,
240
+ include_private=include_private,
241
+ exclude_methods=exclude_methods,
242
+ )
243
+
244
+
245
+ __all__ = [
246
+ "TracingContextProvider",
247
+ "TracingContextProviderFactory",
248
+ "provide_tracing_ctx_provider",
249
+ "get_tracing_ctx_provider",
250
+ "default_trace_mapper",
251
+ "TracedFunc",
252
+ "TracedClass",
253
+ "traced_class",
254
+ ]
@@ -0,0 +1,37 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from fastapi import FastAPI, Request, Response
6
+ from fastapi.exception_handlers import (
7
+ http_exception_handler,
8
+ request_validation_exception_handler,
9
+ )
10
+ from fastapi.exceptions import RequestValidationError
11
+ from fastapi.responses import JSONResponse
12
+ from starlette.exceptions import HTTPException
13
+
14
+ from jararaca.observability.constants import TRACEPARENT_KEY
15
+
16
+
17
+ def setup_fastapi_exception_handler(
18
+ app: FastAPI, trace_header_name: str = "traceparent"
19
+ ) -> None:
20
+ async def base_http_exception_handler(
21
+ request: Request, exc: HTTPException | RequestValidationError
22
+ ) -> JSONResponse | Response:
23
+
24
+ if isinstance(exc, RequestValidationError):
25
+ response = await request_validation_exception_handler(request, exc)
26
+ response.headers[trace_header_name] = request.scope.get(TRACEPARENT_KEY, "")
27
+ return response
28
+ else:
29
+ err_response = await http_exception_handler(request, exc)
30
+
31
+ err_response.headers[trace_header_name] = request.scope.get(
32
+ TRACEPARENT_KEY, ""
33
+ )
34
+ return err_response
35
+
36
+ app.exception_handlers[HTTPException] = base_http_exception_handler
37
+ app.exception_handlers[RequestValidationError] = base_http_exception_handler