rrq 0.7.0__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.
- rrq/cli.py +5 -3
- rrq/cli_commands/base.py +4 -1
- rrq/cli_commands/commands/debug.py +2 -2
- rrq/cli_commands/commands/monitor.py +92 -60
- rrq/cli_commands/commands/queues.py +2 -2
- rrq/cli_commands/utils.py +5 -4
- rrq/client.py +110 -100
- rrq/exporters/__init__.py +1 -0
- rrq/exporters/prometheus.py +90 -0
- rrq/exporters/statsd.py +60 -0
- rrq/hooks.py +80 -47
- rrq/integrations/__init__.py +1 -0
- rrq/integrations/ddtrace.py +456 -0
- rrq/integrations/logfire.py +23 -0
- rrq/integrations/otel.py +325 -0
- rrq/job.py +6 -0
- rrq/settings.py +2 -2
- rrq/store.py +49 -6
- rrq/telemetry.py +129 -0
- rrq/worker.py +259 -94
- {rrq-0.7.0.dist-info → rrq-0.8.0.dist-info}/METADATA +47 -8
- rrq-0.8.0.dist-info/RECORD +34 -0
- {rrq-0.7.0.dist-info → rrq-0.8.0.dist-info}/WHEEL +1 -1
- rrq-0.7.0.dist-info/RECORD +0 -26
- {rrq-0.7.0.dist-info → rrq-0.8.0.dist-info}/entry_points.txt +0 -0
- {rrq-0.7.0.dist-info → rrq-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|