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.
- README.md +121 -0
- jararaca/__init__.py +267 -15
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +106 -0
- jararaca/broker_backend/mapper.py +25 -0
- jararaca/broker_backend/redis_broker_backend.py +168 -0
- jararaca/cli.py +840 -103
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +55 -16
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +5 -1
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +90 -85
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
- jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
- jararaca/messagebus/message.py +31 -0
- jararaca/messagebus/publisher.py +47 -4
- jararaca/messagebus/worker.py +1615 -135
- jararaca/microservice.py +248 -36
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +177 -16
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +8 -2
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +213 -18
- jararaca/persistence/base.py +40 -3
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
- jararaca/persistence/interceptors/constants.py +5 -0
- jararaca/persistence/interceptors/decorators.py +50 -0
- jararaca/persistence/session.py +3 -0
- jararaca/persistence/sort_filter.py +4 -0
- jararaca/persistence/utilities.py +74 -32
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +170 -82
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +120 -41
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +34 -4
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +6 -2
- jararaca/presentation/websocket/websocket_interceptor.py +74 -23
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +81 -0
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +76 -0
- jararaca/rpc/__init__.py +3 -0
- jararaca/rpc/http/__init__.py +101 -0
- jararaca/rpc/http/backends/__init__.py +14 -0
- jararaca/rpc/http/backends/httpx.py +43 -9
- jararaca/rpc/http/backends/otel.py +4 -0
- jararaca/rpc/http/decorators.py +378 -113
- jararaca/rpc/http/httpx.py +3 -0
- jararaca/scheduler/__init__.py +3 -0
- jararaca/scheduler/beat_worker.py +758 -0
- jararaca/scheduler/decorators.py +89 -28
- jararaca/scheduler/types.py +11 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +10 -4
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1126 -189
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +372 -0
- jararaca/utils/retry.py +148 -0
- jararaca-0.4.0a5.dist-info/LICENSE +674 -0
- jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca/messagebus/types.py +0 -30
- jararaca/scheduler/scheduler.py +0 -154
- jararaca/tools/metadata.py +0 -47
- jararaca-0.2.37a12.dist-info/RECORD +0 -63
- /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
66
|
+
class WebSocketTransactionData:
|
|
55
67
|
websocket: WebSocket
|
|
56
68
|
context_type: Literal["websocket"] = "websocket"
|
|
57
69
|
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
APP_TYPE = Literal["http", "worker", "beat"]
|
|
72
|
+
|
|
73
|
+
TransactionData = (
|
|
74
|
+
MessageBusTransactionData
|
|
75
|
+
| HttpTransactionData
|
|
76
|
+
| SchedulerTransactionData
|
|
77
|
+
| WebSocketTransactionData
|
|
61
78
|
)
|
|
62
79
|
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
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(
|
|
72
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
221
|
+
self._get_and_register(provider.use_class, provider.provide)
|
|
126
222
|
elif provider.use_factory:
|
|
127
|
-
self.
|
|
223
|
+
self._get_and_register(provider.use_factory, provider.provide)
|
|
128
224
|
else:
|
|
129
|
-
self.
|
|
225
|
+
self._get_and_register(provider, provider)
|
|
130
226
|
|
|
131
|
-
def
|
|
227
|
+
def _instantiate(self, type_: type[Any] | Callable[..., Any]) -> Any:
|
|
132
228
|
|
|
133
|
-
dependencies = self.
|
|
229
|
+
dependencies = self._parse_dependencies(type_)
|
|
134
230
|
|
|
135
|
-
evaluated_dependencies = {
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
248
|
+
def _parse_dependencies(
|
|
145
249
|
self, provider: type[Any] | Callable[..., Any]
|
|
146
250
|
) -> dict[str, type[Any]]:
|
|
147
251
|
|
|
148
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
305
|
+
def _get_and_register(
|
|
196
306
|
self, item_type: Type[T] | Callable[..., T], bind_to: Any
|
|
197
307
|
) -> T:
|
|
198
|
-
instance = self.
|
|
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
|
-
"
|
|
438
|
+
"AppTransactionContext",
|
|
231
439
|
"AppInterceptor",
|
|
232
440
|
"AppInterceptorWithLifecycle",
|
|
233
441
|
"Container",
|
|
234
442
|
"Microservice",
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
"
|
|
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
|
]
|
|
@@ -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
|
-
|
|
16
|
+
Literal,
|
|
17
|
+
Mapping,
|
|
12
18
|
Protocol,
|
|
19
|
+
Sequence,
|
|
13
20
|
TypeVar,
|
|
21
|
+
Union,
|
|
14
22
|
)
|
|
15
23
|
|
|
16
|
-
from jararaca.microservice import
|
|
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
|
-
|
|
19
|
-
|
|
41
|
+
|
|
42
|
+
class TracingSpan(Protocol): ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TracingSpanContext(Protocol): ...
|
|
20
46
|
|
|
21
47
|
|
|
22
48
|
class TracingContextProvider(Protocol):
|
|
23
49
|
|
|
24
|
-
def
|
|
25
|
-
self, trace_name: str, context_attributes:
|
|
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(
|
|
85
|
+
def root_setup(
|
|
86
|
+
self, app_context: AppTransactionContext
|
|
87
|
+
) -> AsyncContextManager[None]: ...
|
|
32
88
|
|
|
33
|
-
def provide_provider(
|
|
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:
|
|
76
|
-
) ->
|
|
133
|
+
decorated: F,
|
|
134
|
+
) -> F:
|
|
77
135
|
|
|
78
136
|
@wraps(decorated)
|
|
79
137
|
async def wrapper(
|
|
80
|
-
*args:
|
|
81
|
-
**kwargs:
|
|
82
|
-
) ->
|
|
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
|