rrq 0.7.1__py3-none-any.whl → 0.8.0__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.
@@ -0,0 +1,456 @@
1
+ """Datadog (ddtrace) telemetry integration for RRQ.
2
+
3
+ This integration is optional and requires `ddtrace` to be installed in the
4
+ application environment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import AbstractContextManager
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Optional
12
+
13
+ from ..job import Job
14
+ from ..telemetry import EnqueueSpan, JobSpan, Telemetry, configure
15
+
16
+
17
+ def enable(
18
+ *,
19
+ service: str = "rrq",
20
+ env: str | None = None,
21
+ version: str | None = None,
22
+ component: str = "rrq",
23
+ ) -> None:
24
+ """Enable ddtrace-based tracing for RRQ in the current process.
25
+
26
+ This does not call `ddtrace.patch_all()`; it only instruments RRQ spans and
27
+ propagation.
28
+ """
29
+ configure(
30
+ DdtraceTelemetry(
31
+ service=service,
32
+ env=env,
33
+ version=version,
34
+ component=component,
35
+ )
36
+ )
37
+
38
+
39
+ class _DdtraceEnqueueSpan(EnqueueSpan):
40
+ def __init__(
41
+ self,
42
+ *,
43
+ tracer: Any,
44
+ propagator: Any,
45
+ service: str,
46
+ component: str,
47
+ job_id: str,
48
+ function_name: str,
49
+ queue_name: str,
50
+ env: str | None,
51
+ version: str | None,
52
+ ) -> None:
53
+ self._tracer = tracer
54
+ self._propagator = propagator
55
+ self._service = service
56
+ self._component = component
57
+ self._job_id = job_id
58
+ self._function_name = function_name
59
+ self._queue_name = queue_name
60
+ self._env = env
61
+ self._version = version
62
+ self._span = None
63
+
64
+ def __enter__(self) -> Optional[dict[str, str]]:
65
+ span = self._tracer.trace(
66
+ "rrq.enqueue",
67
+ service=self._service,
68
+ resource=self._function_name,
69
+ span_type="queue",
70
+ )
71
+ self._span = span.__enter__()
72
+ self._set_common_tags(self._span)
73
+
74
+ carrier: dict[str, str] = {}
75
+ if self._propagator is not None:
76
+ carrier = _ddtrace_inject(self._propagator, self._span, carrier)
77
+ return {str(k): str(v) for k, v in carrier.items()} or None
78
+
79
+ def __exit__(self, exc_type, exc, tb) -> bool: # type: ignore[override]
80
+ if self._span is not None and exc is not None:
81
+ _set_span_error(self._span, exc)
82
+ try:
83
+ return self._span.__exit__(exc_type, exc, tb) if self._span else False
84
+ finally:
85
+ self._span = None
86
+
87
+ def _set_common_tags(self, span: Any) -> None:
88
+ try:
89
+ span.set_tag("component", self._component)
90
+ span.set_tag("span.kind", "producer")
91
+ span.set_tag("rrq.job_id", self._job_id)
92
+ span.set_tag("rrq.function", self._function_name)
93
+ span.set_tag("rrq.queue", self._queue_name)
94
+ span.set_tag("messaging.system", "redis")
95
+ span.set_tag("messaging.destination.name", self._queue_name)
96
+ span.set_tag("messaging.destination_kind", "queue")
97
+ span.set_tag("messaging.operation", "publish")
98
+ if self._env:
99
+ span.set_tag("env", self._env)
100
+ if self._version:
101
+ span.set_tag("version", self._version)
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ class _DdtraceJobSpan(JobSpan):
107
+ def __init__(
108
+ self,
109
+ *,
110
+ tracer: Any,
111
+ service: str,
112
+ component: str,
113
+ job: Job,
114
+ worker_id: str,
115
+ queue_name: str,
116
+ attempt: int,
117
+ timeout_seconds: float,
118
+ parent_context: Any,
119
+ env: str | None,
120
+ version: str | None,
121
+ ) -> None:
122
+ self._tracer = tracer
123
+ self._service = service
124
+ self._component = component
125
+ self._job = job
126
+ self._worker_id = worker_id
127
+ self._queue_name = queue_name
128
+ self._attempt = attempt
129
+ self._timeout_seconds = timeout_seconds
130
+ self._parent_context = parent_context
131
+ self._env = env
132
+ self._version = version
133
+ self._span = None
134
+
135
+ def __enter__(self) -> "_DdtraceJobSpan":
136
+ span = _trace_with_parent(
137
+ self._tracer,
138
+ name="rrq.job",
139
+ service=self._service,
140
+ resource=self._job.function_name,
141
+ span_type="worker",
142
+ parent_context=self._parent_context,
143
+ )
144
+ self._span = span.__enter__()
145
+ self._set_common_tags(self._span)
146
+ return self
147
+
148
+ def __exit__(self, exc_type, exc, tb) -> bool: # type: ignore[override]
149
+ if self._span is not None and exc is not None:
150
+ _set_span_error(self._span, exc)
151
+ try:
152
+ return self._span.__exit__(exc_type, exc, tb) if self._span else False
153
+ finally:
154
+ self._span = None
155
+
156
+ def success(self, *, duration_seconds: float) -> None:
157
+ self._set_outcome("success", duration_seconds=duration_seconds)
158
+
159
+ def retry(
160
+ self,
161
+ *,
162
+ duration_seconds: float,
163
+ delay_seconds: Optional[float] = None,
164
+ reason: Optional[str] = None,
165
+ ) -> None:
166
+ self._set_outcome(
167
+ "retry",
168
+ duration_seconds=duration_seconds,
169
+ delay_seconds=delay_seconds,
170
+ reason=reason,
171
+ )
172
+
173
+ def dlq(
174
+ self,
175
+ *,
176
+ duration_seconds: float,
177
+ reason: Optional[str] = None,
178
+ error: Optional[BaseException] = None,
179
+ ) -> None:
180
+ if self._span is not None and error is not None:
181
+ _set_span_error(self._span, error)
182
+ self._set_outcome("dlq", duration_seconds=duration_seconds, reason=reason)
183
+
184
+ def timeout(
185
+ self,
186
+ *,
187
+ duration_seconds: float,
188
+ timeout_seconds: float,
189
+ error_message: Optional[str] = None,
190
+ ) -> None:
191
+ if self._span is not None:
192
+ try:
193
+ self._span.set_tag("error", True)
194
+ if error_message:
195
+ self._span.set_tag("error.msg", error_message)
196
+ except Exception:
197
+ pass
198
+ self._set_outcome(
199
+ "timeout",
200
+ duration_seconds=duration_seconds,
201
+ timeout_seconds=timeout_seconds,
202
+ )
203
+
204
+ def cancelled(
205
+ self, *, duration_seconds: float, reason: Optional[str] = None
206
+ ) -> None:
207
+ self._set_outcome(
208
+ "cancelled",
209
+ duration_seconds=duration_seconds,
210
+ reason=reason,
211
+ )
212
+
213
+ def close(self) -> None:
214
+ return
215
+
216
+ def _set_common_tags(self, span: Any) -> None:
217
+ try:
218
+ span.set_tag("component", self._component)
219
+ span.set_tag("span.kind", "consumer")
220
+ span.set_tag("rrq.job_id", self._job.id)
221
+ span.set_tag("rrq.function", self._job.function_name)
222
+ span.set_tag("rrq.queue", self._queue_name)
223
+ span.set_tag("rrq.worker_id", self._worker_id)
224
+ span.set_tag("rrq.attempt", self._attempt)
225
+ span.set_tag("rrq.timeout_seconds", self._timeout_seconds)
226
+ span.set_tag("messaging.system", "redis")
227
+ span.set_tag("messaging.destination.name", self._queue_name)
228
+ span.set_tag("messaging.destination_kind", "queue")
229
+ span.set_tag("messaging.operation", "process")
230
+ _set_span_metric(
231
+ span, "rrq.queue_delay_ms", _calculate_queue_delay_ms(self._job)
232
+ )
233
+ if self._env:
234
+ span.set_tag("env", self._env)
235
+ if self._version:
236
+ span.set_tag("version", self._version)
237
+ except Exception:
238
+ pass
239
+
240
+ def _set_outcome(
241
+ self,
242
+ outcome: str,
243
+ *,
244
+ duration_seconds: float,
245
+ delay_seconds: Optional[float] = None,
246
+ reason: Optional[str] = None,
247
+ timeout_seconds: Optional[float] = None,
248
+ ) -> None:
249
+ if self._span is None:
250
+ return
251
+ try:
252
+ self._span.set_tag("rrq.outcome", outcome)
253
+ _set_span_metric(
254
+ self._span, "rrq.duration_ms", float(duration_seconds) * 1000.0
255
+ )
256
+ if delay_seconds is not None:
257
+ _set_span_metric(
258
+ self._span, "rrq.retry_delay_ms", float(delay_seconds) * 1000.0
259
+ )
260
+ if timeout_seconds is not None:
261
+ self._span.set_tag("rrq.timeout_seconds", float(timeout_seconds))
262
+ if reason:
263
+ self._span.set_tag("rrq.reason", reason)
264
+ except Exception:
265
+ pass
266
+
267
+
268
+ class DdtraceTelemetry(Telemetry):
269
+ """ddtrace-backed RRQ telemetry (traces + propagation)."""
270
+
271
+ enabled: bool = True
272
+
273
+ def __init__(
274
+ self,
275
+ *,
276
+ service: str,
277
+ env: str | None,
278
+ version: str | None,
279
+ component: str,
280
+ ) -> None:
281
+ try:
282
+ from ddtrace import tracer # type: ignore[import-not-found]
283
+ except Exception as e:
284
+ raise RuntimeError(
285
+ "ddtrace is not installed; install rrq with your app's ddtrace dependency."
286
+ ) from e
287
+
288
+ self._tracer = tracer
289
+ self._service = service
290
+ self._env = env
291
+ self._version = version
292
+ self._component = component
293
+
294
+ propagator = None
295
+ try:
296
+ from ddtrace.propagation.http import ( # type: ignore[import-not-found]
297
+ HTTPPropagator,
298
+ )
299
+
300
+ propagator = HTTPPropagator
301
+ except Exception:
302
+ try:
303
+ from ddtrace.propagation.http import ( # type: ignore[import-not-found]
304
+ HttpPropagator,
305
+ )
306
+
307
+ propagator = HttpPropagator
308
+ except Exception:
309
+ propagator = None
310
+ self._propagator = propagator
311
+
312
+ def enqueue_span(
313
+ self, *, job_id: str, function_name: str, queue_name: str
314
+ ) -> EnqueueSpan:
315
+ return _DdtraceEnqueueSpan(
316
+ tracer=self._tracer,
317
+ propagator=self._propagator,
318
+ service=self._service,
319
+ component=self._component,
320
+ job_id=job_id,
321
+ function_name=function_name,
322
+ queue_name=queue_name,
323
+ env=self._env,
324
+ version=self._version,
325
+ )
326
+
327
+ def job_span(
328
+ self,
329
+ *,
330
+ job: Job,
331
+ worker_id: str,
332
+ queue_name: str,
333
+ attempt: int,
334
+ timeout_seconds: float,
335
+ ) -> JobSpan:
336
+ parent_context = None
337
+ if self._propagator is not None and job.trace_context:
338
+ parent_context = _ddtrace_extract(self._propagator, dict(job.trace_context))
339
+ return _DdtraceJobSpan(
340
+ tracer=self._tracer,
341
+ service=self._service,
342
+ component=self._component,
343
+ job=job,
344
+ worker_id=worker_id,
345
+ queue_name=queue_name,
346
+ attempt=attempt,
347
+ timeout_seconds=timeout_seconds,
348
+ parent_context=parent_context,
349
+ env=self._env,
350
+ version=self._version,
351
+ )
352
+
353
+
354
+ def _trace_with_parent(
355
+ tracer: Any,
356
+ *,
357
+ name: str,
358
+ service: str,
359
+ resource: str,
360
+ span_type: str,
361
+ parent_context: Any,
362
+ ) -> AbstractContextManager[Any]:
363
+ try:
364
+ if parent_context is not None:
365
+ return tracer.trace(
366
+ name,
367
+ service=service,
368
+ resource=resource,
369
+ span_type=span_type,
370
+ child_of=parent_context,
371
+ )
372
+ except TypeError:
373
+ pass
374
+
375
+ if parent_context is not None:
376
+ try:
377
+ tracer.context_provider.activate(parent_context)
378
+ except Exception:
379
+ pass
380
+ return tracer.trace(name, service=service, resource=resource, span_type=span_type)
381
+
382
+
383
+ def _ddtrace_inject(
384
+ propagator: Any, span: Any, carrier: dict[str, str]
385
+ ) -> dict[str, str]:
386
+ """Inject a ddtrace span context into a string carrier.
387
+
388
+ ddtrace has historically provided `HTTPPropagator.inject(span.context, headers)`.
389
+ This helper keeps RRQ compatible across ddtrace major versions by trying a
390
+ couple of safe call patterns and returning an empty carrier on failure.
391
+ """
392
+ try:
393
+ propagator.inject(span.context, carrier)
394
+ return carrier
395
+ except TypeError:
396
+ try:
397
+ # Some versions may accept reversed arguments.
398
+ propagator.inject(carrier, span.context)
399
+ return carrier
400
+ except Exception:
401
+ return {}
402
+ except Exception:
403
+ return {}
404
+
405
+
406
+ def _ddtrace_extract(propagator: Any, carrier: dict[str, str]) -> Any:
407
+ """Extract a ddtrace context from a string carrier.
408
+
409
+ Returns a ddtrace Context/SpanContext (or None) suitable for parenting.
410
+ """
411
+ try:
412
+ return propagator.extract(carrier)
413
+ except TypeError:
414
+ try:
415
+ # Some versions may accept keyword carriers.
416
+ return propagator.extract(headers=carrier)
417
+ except Exception:
418
+ return None
419
+ except Exception:
420
+ return None
421
+
422
+
423
+ def _set_span_metric(span: Any, name: str, value: float) -> None:
424
+ try:
425
+ span.set_metric(name, value)
426
+ except Exception:
427
+ try:
428
+ span.set_tag(name, value)
429
+ except Exception:
430
+ pass
431
+
432
+
433
+ def _set_span_error(span: Any, error: BaseException) -> None:
434
+ try:
435
+ span.set_exc_info(type(error), error, error.__traceback__)
436
+ return
437
+ except Exception:
438
+ pass
439
+
440
+ try:
441
+ span.set_tag("error", True)
442
+ span.set_tag("error.type", type(error).__name__)
443
+ span.set_tag("error.msg", str(error))
444
+ except Exception:
445
+ pass
446
+
447
+
448
+ def _calculate_queue_delay_ms(job: Job) -> float:
449
+ scheduled_time = job.next_scheduled_run_time or job.enqueue_time
450
+ dt = scheduled_time
451
+ if dt.tzinfo is None:
452
+ dt = dt.replace(tzinfo=timezone.utc)
453
+ elif dt.tzinfo != timezone.utc:
454
+ dt = dt.astimezone(timezone.utc)
455
+ delay_ms = (datetime.now(timezone.utc) - dt).total_seconds() * 1000.0
456
+ return max(0.0, delay_ms)
@@ -0,0 +1,23 @@
1
+ """Pydantic Logfire integration for RRQ.
2
+
3
+ Logfire is built on OpenTelemetry. RRQ's OTEL integration will emit spans that
4
+ Logfire can collect once Logfire is configured in the application.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .otel import enable as _enable_otel
10
+
11
+
12
+ def enable(*, service_name: str = "rrq") -> None:
13
+ """Enable RRQ tracing for Logfire-enabled applications.
14
+
15
+ This function requires `logfire` to be installed and configured by the app.
16
+ """
17
+ try:
18
+ import logfire # type: ignore[import-not-found] # noqa: F401
19
+ except Exception as e:
20
+ raise RuntimeError(
21
+ "logfire is not installed; install logfire to use rrq.integrations.logfire."
22
+ ) from e
23
+ _enable_otel(service_name=service_name)