Skip to content

Commit 5e061d6

Browse files
committed
add async methods for capture span and capture transaction
1 parent 82ba28f commit 5e061d6

2 files changed

Lines changed: 260 additions & 19 deletions

File tree

archipy/helpers/decorators/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .singleton import singleton_decorator
88
from .timeout import timeout_decorator
99
from .timing import timing_decorator
10-
from .tracing import capture_span, capture_transaction
10+
from .tracing import async_capture_span, async_capture_transaction, capture_span, capture_transaction
1111

1212
# SQLAlchemy decorators are imported lazily to avoid requiring SQLAlchemy
1313
# when using archipy without the sqlalchemy extra (e.g., archipy[scylladb])
@@ -91,6 +91,8 @@ def __getattr__(name: str) -> object:
9191

9292

9393
__all__ = [
94+
"async_capture_span",
95+
"async_capture_transaction",
9496
"async_postgres_sqlalchemy_atomic_decorator",
9597
"async_sqlite_sqlalchemy_atomic_decorator",
9698
"async_starrocks_sqlalchemy_atomic_decorator",

archipy/helpers/decorators/tracing.py

Lines changed: 257 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
7878
logging.exception("Failed to initialize Sentry or start transaction")
7979

8080
# Initialize and track with Elastic APM if enabled
81+
elastic_client = None
8182
if config.ELASTIC_APM.IS_ENABLED:
8283
try:
83-
import elasticapm
84+
import elasticapm # type: ignore[import-not-found]
8485

8586
# Initialize Elastic APM client with config
8687
elastic_client = elasticapm.get_client()
@@ -95,22 +96,133 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
9596
try:
9697
# Execute the function
9798
result = func(*args, **kwargs)
99+
100+
# Mark transaction as successful
101+
if sentry_transaction:
102+
sentry_transaction.set_status("ok")
103+
if elastic_client:
104+
elastic_client.end_transaction(name=transaction_name, result="success")
105+
106+
return result
98107
except Exception:
99108
# Mark transaction as failed and capture the exception
100109
if sentry_transaction:
101110
sentry_transaction.set_status("internal_error")
102111
if elastic_client:
103-
elastic_client.end_transaction(name=transaction_name, result="error") # type: ignore[no-untyped-call]
104-
105-
# Re-raise the exception
112+
elastic_client.end_transaction(name=transaction_name, result="error")
106113
raise
107-
else:
114+
finally:
115+
# Clean up Sentry transaction
116+
if sentry_transaction:
117+
try:
118+
sentry_transaction.__exit__(None, None, None)
119+
except Exception:
120+
logging.exception("Error closing Sentry transaction")
121+
122+
return cast(F, wrapper)
123+
124+
return decorator
125+
126+
127+
def async_capture_transaction[F: Callable[..., Any]](
128+
name: str | None = None,
129+
*,
130+
op: str = "function",
131+
description: str | None = None,
132+
) -> Callable[[F], F]:
133+
"""Decorator to capture a transaction for the decorated async function.
134+
135+
This decorator creates a transaction span around the execution of the decorated async function.
136+
It integrates with both Sentry and Elastic APM based on the application configuration.
137+
138+
Args:
139+
name: Name of the transaction. If None, uses the function name.
140+
op: Operation type/category for the transaction. Defaults to "function".
141+
description: Optional description of the transaction.
142+
143+
Returns:
144+
The decorated async function with transaction tracing capabilities.
145+
146+
Example:
147+
```python
148+
@async_capture_transaction(name="user_processing", op="business_logic")
149+
async def process_user_data(user_id: int) -> dict[str, Any]:
150+
# Your async business logic here
151+
return {"user_id": user_id, "status": "processed"}
152+
153+
# Transaction will be automatically captured when function is called
154+
result = await process_user_data(123)
155+
```
156+
"""
157+
158+
def decorator(func: F) -> F:
159+
transaction_name = name or func.__name__
160+
161+
@functools.wraps(func)
162+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
163+
config: Any = BaseConfig.global_config()
164+
165+
# Initialize and track with Sentry if enabled
166+
sentry_transaction = None
167+
if config.SENTRY.IS_ENABLED:
168+
try:
169+
import sentry_sdk
170+
171+
# Initialize Sentry if not already done
172+
if not sentry_sdk.Hub.current.client:
173+
sentry_sdk.init(
174+
dsn=config.SENTRY.DSN,
175+
debug=config.SENTRY.DEBUG,
176+
release=config.SENTRY.RELEASE,
177+
sample_rate=config.SENTRY.SAMPLE_RATE,
178+
traces_sample_rate=config.SENTRY.TRACES_SAMPLE_RATE,
179+
environment=getattr(config, "ENVIRONMENT", None),
180+
)
181+
sentry_transaction = sentry_sdk.start_transaction(
182+
name=transaction_name,
183+
op=op,
184+
description=description or transaction_name,
185+
)
186+
sentry_transaction.__enter__()
187+
except ImportError:
188+
logging.debug("sentry_sdk is not installed, skipping Sentry transaction capture.")
189+
except Exception:
190+
logging.exception("Failed to initialize Sentry or start transaction")
191+
192+
# Initialize and track with Elastic APM if enabled
193+
elastic_client = None
194+
if config.ELASTIC_APM.IS_ENABLED:
195+
try:
196+
import elasticapm
197+
198+
# Initialize Elastic APM client with config
199+
elastic_client = elasticapm.get_client()
200+
if not elastic_client:
201+
elastic_client = elasticapm.Client(config.ELASTIC_APM.model_dump())
202+
elastic_client.begin_transaction(transaction_type="function")
203+
except ImportError:
204+
logging.debug("elasticapm is not installed, skipping Elastic APM transaction capture.")
205+
except Exception:
206+
logging.exception("Failed to initialize Elastic APM or start transaction")
207+
208+
try:
209+
# Execute the async function
210+
result = await func(*args, **kwargs)
211+
108212
# Mark transaction as successful
109213
if sentry_transaction:
110214
sentry_transaction.set_status("ok")
111215
if elastic_client:
112-
elastic_client.end_transaction(name=transaction_name, result="success") # type: ignore[no-untyped-call]
216+
elastic_client.end_transaction(name=transaction_name, result="success")
217+
113218
return result
219+
except Exception:
220+
# Mark transaction as failed and capture the exception
221+
if sentry_transaction:
222+
sentry_transaction.set_status("internal_error")
223+
if elastic_client:
224+
elastic_client.end_transaction(name=transaction_name, result="error")
225+
raise
114226
finally:
115227
# Clean up Sentry transaction
116228
if sentry_transaction:
@@ -190,6 +302,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
190302
sentry_span.__enter__()
191303
except ImportError:
192304
logging.debug("sentry_sdk is not installed, skipping Sentry span capture.")
305+
except Exception:
306+
logging.exception("Failed to start Sentry span")
193307

194308
# Track with Elastic APM if enabled
195309
elastic_client = None
@@ -198,46 +312,171 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
198312
try:
199313
import elasticapm
200314

201-
elastic_client = elasticapm.get_client() # type: ignore[attr-defined]
315+
elastic_client = elasticapm.get_client()
202316
if elastic_client:
203-
elastic_span = elastic_client.begin_span( # type: ignore[attr-defined]
317+
elastic_span = elastic_client.begin_span(
204318
name=span_name,
205319
span_type=op,
206320
)
207321
except ImportError:
208322
logging.debug("elasticapm is not installed, skipping Elastic APM span capture.")
323+
except Exception:
324+
logging.exception("Failed to start Elastic APM span")
209325

210326
try:
211327
# Execute the function
212328
result = func(*args, **kwargs)
329+
330+
# Mark span as successful
331+
if sentry_span:
332+
sentry_span.set_status("ok")
333+
334+
return result
213335
except Exception as e:
214336
# Mark span as failed and capture the exception
215337
if sentry_span:
216338
sentry_span.set_status("internal_error")
217-
218-
# Add exception context to spans
219-
if sentry_span:
220339
sentry_span.set_tag("error", True)
221340
sentry_span.set_data("exception", str(e))
222341

223342
if elastic_span and elastic_client:
224-
elastic_client.capture_exception() # type: ignore[no-untyped-call]
343+
elastic_client.capture_exception()
225344

226-
# Re-raise the exception
227345
raise
228-
else:
229-
# Mark span as successful
346+
finally:
347+
# Clean up spans
348+
if elastic_span and elastic_client:
349+
try:
350+
elastic_client.end_span()
351+
except Exception:
352+
logging.exception("Error closing Elastic APM span")
353+
354+
if sentry_span:
355+
try:
356+
sentry_span.__exit__(None, None, None)
357+
except Exception:
358+
logging.exception("Error closing Sentry span")
359+
360+
return cast(F, wrapper)
361+
362+
return decorator
363+
364+
365+
def async_capture_span[F: Callable[..., Any]](
366+
name: str | None = None,
367+
*,
368+
op: str = "function",
369+
description: str | None = None,
370+
) -> Callable[[F], F]:
371+
"""Decorator to capture a span for the decorated async function.
372+
373+
This decorator creates a span around the execution of the decorated async function.
374+
Spans are child operations within a transaction and help provide detailed
375+
performance insights. Works with both Sentry and Elastic APM.
376+
377+
Args:
378+
name: Name of the span. If None, uses the function name.
379+
op: Operation type/category for the span. Defaults to "function".
380+
description: Optional description of the span.
381+
382+
Returns:
383+
The decorated async function with span tracing capabilities.
384+
385+
Example:
386+
```python
387+
@async_capture_transaction(name="user_processing")
388+
async def process_user_data(user_id: int) -> dict[str, Any]:
389+
user = await get_user(user_id)
390+
processed_data = await transform_data(user)
391+
await save_result(processed_data)
392+
return processed_data
393+
394+
@async_capture_span(name="database_query", op="db")
395+
async def get_user(user_id: int) -> dict[str, Any]:
396+
# Async database query logic here
397+
return {"id": user_id, "name": "John"}
398+
399+
@async_capture_span(name="data_transformation", op="processing")
400+
async def transform_data(user: dict[str, Any]) -> dict[str, Any]:
401+
# Async data transformation logic
402+
return {"processed": True, **user}
403+
404+
@async_capture_span(name="save_operation", op="db")
405+
async def save_result(data: dict[str, Any]) -> None:
406+
# Async save logic here
407+
pass
408+
```
409+
"""
410+
411+
def decorator(func: F) -> F:
412+
span_name = name or func.__name__
413+
414+
@functools.wraps(func)
415+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
416+
config: Any = BaseConfig.global_config()
417+
418+
# Track with Sentry if enabled
419+
sentry_span = None
420+
if config.SENTRY.IS_ENABLED:
421+
try:
422+
import sentry_sdk
423+
424+
sentry_span = sentry_sdk.start_span(
425+
op=op,
426+
description=span_name,
427+
)
428+
sentry_span.__enter__()
429+
except ImportError:
430+
logging.debug("sentry_sdk is not installed, skipping Sentry span capture.")
431+
except Exception:
432+
logging.exception("Failed to start Sentry span")
433+
434+
# Track with Elastic APM if enabled
435+
elastic_client = None
436+
elastic_span_context = None
437+
if config.ELASTIC_APM.IS_ENABLED:
438+
try:
439+
import elasticapm
440+
441+
elastic_client = elasticapm.get_client()
442+
if elastic_client:
443+
# Use async context manager for proper async handling
444+
elastic_span_context = elasticapm.async_capture_span(span_name, span_type=op)
445+
await elastic_span_context.__aenter__()
446+
except ImportError:
447+
logging.debug("elasticapm is not installed, skipping Elastic APM span capture.")
448+
except Exception:
449+
logging.exception("Failed to start Elastic APM span")
450+
451+
try:
452+
# Execute the async function
453+
result = await func(*args, **kwargs)
454+
455+
# Mark Sentry span as successful
230456
if sentry_span:
231457
sentry_span.set_status("ok")
458+
232459
return result
460+
except Exception as e:
461+
# Mark span as failed
462+
if sentry_span:
463+
sentry_span.set_status("internal_error")
464+
sentry_span.set_tag("error", True)
465+
sentry_span.set_data("exception", str(e))
466+
467+
if elastic_client:
468+
elastic_client.capture_exception()
469+
470+
raise
233471
finally:
234-
# Clean up spans
235-
if elastic_span and elastic_client:
472+
# Clean up Elastic APM span
473+
if elastic_span_context:
236474
try:
237-
elastic_client.end_span() # type: ignore[attr-defined]
475+
await elastic_span_context.__aexit__(None, None, None)
238476
except Exception:
239477
logging.exception("Error closing Elastic APM span")
240478

479+
# Clean up Sentry span
241480
if sentry_span:
242481
try:
243482
sentry_span.__exit__(None, None, None)

0 commit comments

Comments
 (0)