queuelens 0.1.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.
- queuelens/__init__.py +5 -0
- queuelens/client.py +91 -0
- queuelens/config.py +100 -0
- queuelens/events.py +46 -0
- queuelens/exceptions.py +6 -0
- queuelens/instrumentation/__init__.py +3 -0
- queuelens/instrumentation/celery.py +546 -0
- queuelens/transport/__init__.py +0 -0
- queuelens/transport/buffer.py +56 -0
- queuelens/transport/flusher.py +167 -0
- queuelens/transport/sender.py +83 -0
- queuelens-0.1.0.dist-info/METADATA +258 -0
- queuelens-0.1.0.dist-info/RECORD +15 -0
- queuelens-0.1.0.dist-info/WHEEL +4 -0
- queuelens-0.1.0.dist-info/licenses/LICENSE +21 -0
queuelens/__init__.py
ADDED
queuelens/client.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
from queuelens.config import QueueLensConfig
|
|
6
|
+
from queuelens.exceptions import QueueLensConfigurationError
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueueLens:
|
|
12
|
+
"""Public client for integrating QueueLens with Celery applications."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
api_key: str | None = None,
|
|
17
|
+
endpoint: str | None = None,
|
|
18
|
+
batch_size: int | None = None,
|
|
19
|
+
flush_interval: float | None = None,
|
|
20
|
+
timeout: float | None = None,
|
|
21
|
+
buffer_capacity: int | None = None,
|
|
22
|
+
max_batches_per_cycle: int | None = None,
|
|
23
|
+
shutdown_timeout: float | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
try:
|
|
26
|
+
self._config = QueueLensConfig.from_env(
|
|
27
|
+
api_key=api_key,
|
|
28
|
+
endpoint=endpoint,
|
|
29
|
+
batch_size=batch_size,
|
|
30
|
+
flush_interval=flush_interval,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
buffer_capacity=buffer_capacity,
|
|
33
|
+
max_batches_per_cycle=max_batches_per_cycle,
|
|
34
|
+
shutdown_timeout=shutdown_timeout,
|
|
35
|
+
)
|
|
36
|
+
except (TypeError, ValueError) as exc:
|
|
37
|
+
raise QueueLensConfigurationError(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
self._integrations: dict[int, Any] = {}
|
|
40
|
+
self._closed = False
|
|
41
|
+
|
|
42
|
+
def instrument(self, celery_app: Any) -> Any:
|
|
43
|
+
"""Instrument a Celery application with QueueLens."""
|
|
44
|
+
|
|
45
|
+
if self._closed:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"QueueLens client is closed and cannot instrument applications"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from queuelens.instrumentation.celery import CeleryInstrumentation
|
|
51
|
+
|
|
52
|
+
app_id = id(celery_app)
|
|
53
|
+
|
|
54
|
+
if app_id in self._integrations:
|
|
55
|
+
return self._integrations[app_id]
|
|
56
|
+
|
|
57
|
+
integration = CeleryInstrumentation(self._config)
|
|
58
|
+
integration.instrument(celery_app)
|
|
59
|
+
|
|
60
|
+
self._integrations[app_id] = integration
|
|
61
|
+
|
|
62
|
+
logger.info("QueueLens instrumented Celery application")
|
|
63
|
+
|
|
64
|
+
return integration
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
"""Close all registered integrations."""
|
|
68
|
+
|
|
69
|
+
self._closed = True
|
|
70
|
+
|
|
71
|
+
for app_id, integration in list(self._integrations.items()):
|
|
72
|
+
try:
|
|
73
|
+
integration.close()
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.exception(
|
|
76
|
+
"QueueLens failed to close an integration for app %s",
|
|
77
|
+
app_id,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
self._integrations.pop(app_id, None)
|
|
81
|
+
|
|
82
|
+
logger.info("QueueLens client shutdown completed")
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> Self:
|
|
85
|
+
if self._closed:
|
|
86
|
+
raise RuntimeError("QueueLens client is already closed")
|
|
87
|
+
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
91
|
+
self.close()
|
queuelens/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class QueueLensConfig:
|
|
7
|
+
api_key: str
|
|
8
|
+
endpoint: str
|
|
9
|
+
batch_size: int = 100
|
|
10
|
+
flush_interval: float = 5.0
|
|
11
|
+
timeout: float = 5.0
|
|
12
|
+
buffer_capacity: int = 1000
|
|
13
|
+
max_batches_per_cycle: int = 10
|
|
14
|
+
shutdown_timeout: float = 10.0
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
if not self.api_key.strip():
|
|
18
|
+
raise ValueError("API key cannot be empty")
|
|
19
|
+
|
|
20
|
+
if not self.endpoint.strip():
|
|
21
|
+
raise ValueError("Endpoint cannot be empty")
|
|
22
|
+
|
|
23
|
+
if self.batch_size < 1:
|
|
24
|
+
raise ValueError("batch_size must be at least 1")
|
|
25
|
+
|
|
26
|
+
if self.flush_interval <= 0:
|
|
27
|
+
raise ValueError("flush_interval must be greater than 0")
|
|
28
|
+
|
|
29
|
+
if self.timeout <= 0:
|
|
30
|
+
raise ValueError("timeout must be greater than 0")
|
|
31
|
+
|
|
32
|
+
if self.buffer_capacity < 1:
|
|
33
|
+
raise ValueError("buffer_capacity must be at least 1")
|
|
34
|
+
|
|
35
|
+
if self.max_batches_per_cycle < 1:
|
|
36
|
+
raise ValueError("max_batches_per_cycle must be at least 1")
|
|
37
|
+
|
|
38
|
+
if self.shutdown_timeout <= 0:
|
|
39
|
+
raise ValueError("shutdown_timeout must be greater than 0")
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_env(
|
|
43
|
+
cls,
|
|
44
|
+
*,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
endpoint: str | None = None,
|
|
47
|
+
batch_size: int | None = None,
|
|
48
|
+
flush_interval: float | None = None,
|
|
49
|
+
timeout: float | None = None,
|
|
50
|
+
buffer_capacity: int | None = None,
|
|
51
|
+
max_batches_per_cycle: int | None = None,
|
|
52
|
+
shutdown_timeout: float | None = None,
|
|
53
|
+
) -> "QueueLensConfig":
|
|
54
|
+
resolved_api_key = (
|
|
55
|
+
api_key if api_key is not None else os.getenv("QUEUELENS_API_KEY")
|
|
56
|
+
)
|
|
57
|
+
resolved_endpoint = (
|
|
58
|
+
endpoint if endpoint is not None else os.getenv("QUEUELENS_ENDPOINT")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if resolved_api_key is None:
|
|
62
|
+
raise ValueError("QUEUELENS_API_KEY is required")
|
|
63
|
+
|
|
64
|
+
if resolved_endpoint is None:
|
|
65
|
+
raise ValueError("QUEUELENS_ENDPOINT is required")
|
|
66
|
+
|
|
67
|
+
return cls(
|
|
68
|
+
api_key=resolved_api_key,
|
|
69
|
+
endpoint=resolved_endpoint,
|
|
70
|
+
batch_size=(
|
|
71
|
+
batch_size
|
|
72
|
+
if batch_size is not None
|
|
73
|
+
else int(os.getenv("QUEUELENS_BATCH_SIZE", "100"))
|
|
74
|
+
),
|
|
75
|
+
flush_interval=(
|
|
76
|
+
flush_interval
|
|
77
|
+
if flush_interval is not None
|
|
78
|
+
else float(os.getenv("QUEUELENS_FLUSH_INTERVAL", "5"))
|
|
79
|
+
),
|
|
80
|
+
timeout=(
|
|
81
|
+
timeout
|
|
82
|
+
if timeout is not None
|
|
83
|
+
else float(os.getenv("QUEUELENS_TIMEOUT", "5"))
|
|
84
|
+
),
|
|
85
|
+
buffer_capacity=(
|
|
86
|
+
buffer_capacity
|
|
87
|
+
if buffer_capacity is not None
|
|
88
|
+
else int(os.getenv("QUEUELENS_BUFFER_CAPACITY", "1000"))
|
|
89
|
+
),
|
|
90
|
+
max_batches_per_cycle=(
|
|
91
|
+
max_batches_per_cycle
|
|
92
|
+
if max_batches_per_cycle is not None
|
|
93
|
+
else int(os.getenv("QUEUELENS_MAX_BATCHES_PER_CYCLE", "10"))
|
|
94
|
+
),
|
|
95
|
+
shutdown_timeout=(
|
|
96
|
+
shutdown_timeout
|
|
97
|
+
if shutdown_timeout is not None
|
|
98
|
+
else float(os.getenv("QUEUELENS_SHUTDOWN_TIMEOUT", "10"))
|
|
99
|
+
),
|
|
100
|
+
)
|
queuelens/events.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JobEventType(StrEnum):
|
|
9
|
+
RECEIVED = "received"
|
|
10
|
+
STARTED = "started"
|
|
11
|
+
SUCCEEDED = "succeeded"
|
|
12
|
+
FAILED = "failed"
|
|
13
|
+
RETRIED = "retried"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JobEvent(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
|
|
19
|
+
event_id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
|
20
|
+
task_id: str = Field(min_length=1)
|
|
21
|
+
task_name: str = Field(min_length=1)
|
|
22
|
+
event_type: JobEventType
|
|
23
|
+
occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
24
|
+
|
|
25
|
+
worker_name: str | None = None
|
|
26
|
+
queue_name: str | None = None
|
|
27
|
+
attempt: int = Field(default=1, ge=1)
|
|
28
|
+
|
|
29
|
+
exchange: str | None = Field(default=None, max_length=255)
|
|
30
|
+
routing_key: str | None = Field(default=None, max_length=255)
|
|
31
|
+
|
|
32
|
+
exception_type: str | None = Field(default=None, max_length=255)
|
|
33
|
+
exception_message: str | None = Field(default=None, max_length=4096)
|
|
34
|
+
traceback: str | None = Field(default=None, max_length=16000)
|
|
35
|
+
|
|
36
|
+
@field_validator("occurred_at")
|
|
37
|
+
@classmethod
|
|
38
|
+
def require_timezone(cls, value: datetime) -> datetime:
|
|
39
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
40
|
+
raise ValueError("occurred_at must include timezone information")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IngestionResponse(BaseModel):
|
|
45
|
+
accepted_count: int = Field(ge=0)
|
|
46
|
+
duplicate_count: int = Field(ge=0)
|
queuelens/exceptions.py
ADDED