dooers-workers 0.2.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.
- dooers/__init__.py +73 -0
- dooers/broadcast.py +180 -0
- dooers/config.py +22 -0
- dooers/features/__init__.py +0 -0
- dooers/features/analytics/__init__.py +12 -0
- dooers/features/analytics/collector.py +219 -0
- dooers/features/analytics/models.py +50 -0
- dooers/features/analytics/worker_analytics.py +100 -0
- dooers/features/settings/__init__.py +12 -0
- dooers/features/settings/broadcaster.py +97 -0
- dooers/features/settings/models.py +72 -0
- dooers/features/settings/worker_settings.py +85 -0
- dooers/handlers/__init__.py +16 -0
- dooers/handlers/memory.py +105 -0
- dooers/handlers/request.py +12 -0
- dooers/handlers/response.py +66 -0
- dooers/handlers/router.py +957 -0
- dooers/migrations/__init__.py +3 -0
- dooers/migrations/schemas.py +126 -0
- dooers/persistence/__init__.py +9 -0
- dooers/persistence/base.py +42 -0
- dooers/persistence/postgres.py +459 -0
- dooers/persistence/sqlite.py +433 -0
- dooers/protocol/__init__.py +108 -0
- dooers/protocol/frames.py +298 -0
- dooers/protocol/models.py +72 -0
- dooers/protocol/parser.py +19 -0
- dooers/registry.py +101 -0
- dooers/server.py +162 -0
- dooers/settings.py +3 -0
- dooers_workers-0.2.0.dist-info/METADATA +228 -0
- dooers_workers-0.2.0.dist-info/RECORD +33 -0
- dooers_workers-0.2.0.dist-info/WHEEL +4 -0
dooers/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from dooers.broadcast import BroadcastManager
|
|
2
|
+
from dooers.config import WorkerConfig
|
|
3
|
+
from dooers.features.analytics import (
|
|
4
|
+
AnalyticsBatch,
|
|
5
|
+
AnalyticsCollector,
|
|
6
|
+
AnalyticsEvent,
|
|
7
|
+
AnalyticsEventPayload,
|
|
8
|
+
FeedbackData,
|
|
9
|
+
WorkerAnalytics,
|
|
10
|
+
)
|
|
11
|
+
from dooers.features.settings import (
|
|
12
|
+
SettingsBroadcaster,
|
|
13
|
+
SettingsField,
|
|
14
|
+
SettingsFieldType,
|
|
15
|
+
SettingsSchema,
|
|
16
|
+
SettingsSelectOption,
|
|
17
|
+
WorkerSettings,
|
|
18
|
+
)
|
|
19
|
+
from dooers.handlers.memory import WorkerMemory
|
|
20
|
+
from dooers.handlers.request import WorkerRequest
|
|
21
|
+
from dooers.handlers.response import WorkerResponse
|
|
22
|
+
from dooers.persistence.base import Persistence
|
|
23
|
+
from dooers.protocol.models import (
|
|
24
|
+
Actor,
|
|
25
|
+
ContentPart,
|
|
26
|
+
DocumentPart,
|
|
27
|
+
EventType,
|
|
28
|
+
ImagePart,
|
|
29
|
+
Run,
|
|
30
|
+
RunStatus,
|
|
31
|
+
TextPart,
|
|
32
|
+
Thread,
|
|
33
|
+
ThreadEvent,
|
|
34
|
+
)
|
|
35
|
+
from dooers.registry import ConnectionRegistry
|
|
36
|
+
from dooers.server import WorkerServer
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Core
|
|
40
|
+
"WorkerConfig",
|
|
41
|
+
"WorkerServer",
|
|
42
|
+
"WorkerResponse",
|
|
43
|
+
"WorkerMemory",
|
|
44
|
+
"WorkerRequest",
|
|
45
|
+
"ConnectionRegistry",
|
|
46
|
+
"BroadcastManager",
|
|
47
|
+
"Persistence",
|
|
48
|
+
# Protocol models
|
|
49
|
+
"ContentPart",
|
|
50
|
+
"TextPart",
|
|
51
|
+
"ImagePart",
|
|
52
|
+
"DocumentPart",
|
|
53
|
+
"Thread",
|
|
54
|
+
"ThreadEvent",
|
|
55
|
+
"Run",
|
|
56
|
+
"RunStatus",
|
|
57
|
+
"Actor",
|
|
58
|
+
"EventType",
|
|
59
|
+
# Analytics
|
|
60
|
+
"AnalyticsEvent",
|
|
61
|
+
"AnalyticsEventPayload",
|
|
62
|
+
"AnalyticsBatch",
|
|
63
|
+
"AnalyticsCollector",
|
|
64
|
+
"FeedbackData",
|
|
65
|
+
"WorkerAnalytics",
|
|
66
|
+
# Settings
|
|
67
|
+
"SettingsFieldType",
|
|
68
|
+
"SettingsField",
|
|
69
|
+
"SettingsSelectOption",
|
|
70
|
+
"SettingsSchema",
|
|
71
|
+
"SettingsBroadcaster",
|
|
72
|
+
"WorkerSettings",
|
|
73
|
+
]
|
dooers/broadcast.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from dooers.protocol.frames import (
|
|
8
|
+
EventAppendPayload,
|
|
9
|
+
S2C_EventAppend,
|
|
10
|
+
S2C_ThreadUpsert,
|
|
11
|
+
ThreadUpsertPayload,
|
|
12
|
+
)
|
|
13
|
+
from dooers.protocol.models import Actor, ContentPart, Thread, ThreadEvent
|
|
14
|
+
from dooers.protocol.parser import serialize_frame
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from dooers.persistence.base import Persistence
|
|
18
|
+
from dooers.registry import ConnectionRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _generate_id() -> str:
|
|
22
|
+
return str(uuid.uuid4())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now() -> datetime:
|
|
26
|
+
return datetime.now(UTC)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BroadcastManager:
|
|
30
|
+
"""
|
|
31
|
+
Manages broadcasting events from backend to connected WebSocket clients.
|
|
32
|
+
|
|
33
|
+
Use this to push messages, notifications, or events from external systems
|
|
34
|
+
(webhooks, background jobs, etc.) to all connected users of a worker.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
registry: ConnectionRegistry,
|
|
40
|
+
persistence: Persistence,
|
|
41
|
+
subscriptions: dict[str, set[str]], # worker_id -> set of subscribed thread_ids per connection
|
|
42
|
+
) -> None:
|
|
43
|
+
self._registry = registry
|
|
44
|
+
self._persistence = persistence
|
|
45
|
+
self._subscriptions = subscriptions # Shared with Router instances
|
|
46
|
+
|
|
47
|
+
async def send_event(
|
|
48
|
+
self,
|
|
49
|
+
worker_id: str,
|
|
50
|
+
thread_id: str,
|
|
51
|
+
content: list[ContentPart],
|
|
52
|
+
actor: Actor = "system",
|
|
53
|
+
user_id: str | None = None,
|
|
54
|
+
user_name: str | None = None,
|
|
55
|
+
user_email: str | None = None,
|
|
56
|
+
) -> tuple[ThreadEvent, int]:
|
|
57
|
+
"""
|
|
58
|
+
Create and broadcast an event from the backend to all connected clients.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
worker_id: The worker instance to broadcast to
|
|
62
|
+
thread_id: The thread to add the event to
|
|
63
|
+
content: Message content (text, images, documents)
|
|
64
|
+
actor: Event actor (default: "system")
|
|
65
|
+
user_id: Optional user identifier
|
|
66
|
+
user_name: Optional display name
|
|
67
|
+
user_email: Optional email
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (created event, number of connections notified)
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: If thread not found
|
|
74
|
+
PermissionError: If thread belongs to different worker
|
|
75
|
+
"""
|
|
76
|
+
# Verify thread exists and belongs to worker
|
|
77
|
+
thread = await self._persistence.get_thread(thread_id)
|
|
78
|
+
if not thread:
|
|
79
|
+
raise ValueError(f"Thread {thread_id} not found")
|
|
80
|
+
if thread.worker_id != worker_id:
|
|
81
|
+
raise PermissionError(f"Thread {thread_id} belongs to different worker")
|
|
82
|
+
|
|
83
|
+
now = _now()
|
|
84
|
+
|
|
85
|
+
# Create the event
|
|
86
|
+
event = ThreadEvent(
|
|
87
|
+
id=_generate_id(),
|
|
88
|
+
thread_id=thread_id,
|
|
89
|
+
run_id=None,
|
|
90
|
+
type="message",
|
|
91
|
+
actor=actor,
|
|
92
|
+
user_id=user_id,
|
|
93
|
+
user_name=user_name,
|
|
94
|
+
user_email=user_email,
|
|
95
|
+
content=content,
|
|
96
|
+
data=None,
|
|
97
|
+
created_at=now,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Persist
|
|
101
|
+
await self._persistence.create_event(event)
|
|
102
|
+
|
|
103
|
+
# Update thread timestamp
|
|
104
|
+
thread.last_event_at = now
|
|
105
|
+
thread.updated_at = now
|
|
106
|
+
await self._persistence.update_thread(thread)
|
|
107
|
+
|
|
108
|
+
# Broadcast to all connections for this worker
|
|
109
|
+
frame = S2C_EventAppend(
|
|
110
|
+
id=_generate_id(),
|
|
111
|
+
payload=EventAppendPayload(thread_id=thread_id, events=[event]),
|
|
112
|
+
)
|
|
113
|
+
message = serialize_frame(frame)
|
|
114
|
+
count = await self._registry.broadcast(worker_id, message)
|
|
115
|
+
|
|
116
|
+
return event, count
|
|
117
|
+
|
|
118
|
+
async def send_thread_update(
|
|
119
|
+
self,
|
|
120
|
+
worker_id: str,
|
|
121
|
+
thread: Thread,
|
|
122
|
+
) -> int:
|
|
123
|
+
"""
|
|
124
|
+
Broadcast a thread update to all connected clients.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
worker_id: The worker instance to broadcast to
|
|
128
|
+
thread: The thread that was updated
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Number of connections notified
|
|
132
|
+
"""
|
|
133
|
+
if thread.worker_id != worker_id:
|
|
134
|
+
raise PermissionError(f"Thread {thread.id} belongs to different worker")
|
|
135
|
+
|
|
136
|
+
frame = S2C_ThreadUpsert(
|
|
137
|
+
id=_generate_id(),
|
|
138
|
+
payload=ThreadUpsertPayload(thread=thread),
|
|
139
|
+
)
|
|
140
|
+
message = serialize_frame(frame)
|
|
141
|
+
return await self._registry.broadcast(worker_id, message)
|
|
142
|
+
|
|
143
|
+
async def create_thread_and_broadcast(
|
|
144
|
+
self,
|
|
145
|
+
worker_id: str,
|
|
146
|
+
user_id: str | None = None,
|
|
147
|
+
title: str | None = None,
|
|
148
|
+
) -> tuple[Thread, int]:
|
|
149
|
+
"""
|
|
150
|
+
Create a new thread and broadcast it to all connected clients.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
worker_id: The worker instance
|
|
154
|
+
user_id: Optional user who owns the thread
|
|
155
|
+
title: Optional thread title
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Tuple of (created thread, number of connections notified)
|
|
159
|
+
"""
|
|
160
|
+
now = _now()
|
|
161
|
+
thread = Thread(
|
|
162
|
+
id=_generate_id(),
|
|
163
|
+
worker_id=worker_id,
|
|
164
|
+
user_id=user_id,
|
|
165
|
+
title=title,
|
|
166
|
+
created_at=now,
|
|
167
|
+
updated_at=now,
|
|
168
|
+
last_event_at=now,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
await self._persistence.create_thread(thread)
|
|
172
|
+
|
|
173
|
+
frame = S2C_ThreadUpsert(
|
|
174
|
+
id=_generate_id(),
|
|
175
|
+
payload=ThreadUpsertPayload(thread=thread),
|
|
176
|
+
)
|
|
177
|
+
message = serialize_frame(frame)
|
|
178
|
+
count = await self._registry.broadcast(worker_id, message)
|
|
179
|
+
|
|
180
|
+
return thread, count
|
dooers/config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TYPE_CHECKING, Literal
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from dooers.features.settings.models import SettingsSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class WorkerConfig:
|
|
10
|
+
database_url: str
|
|
11
|
+
database_type: Literal["postgres", "sqlite"]
|
|
12
|
+
table_prefix: str = "worker_"
|
|
13
|
+
auto_migrate: bool = True
|
|
14
|
+
|
|
15
|
+
# Analytics (optional, defaults from settings.py)
|
|
16
|
+
analytics_enabled: bool = True
|
|
17
|
+
analytics_webhook_url: str | None = None # Override default webhook URL
|
|
18
|
+
analytics_batch_size: int | None = None # Override default
|
|
19
|
+
analytics_flush_interval: float | None = None # Override default
|
|
20
|
+
|
|
21
|
+
# Settings
|
|
22
|
+
settings_schema: "SettingsSchema | None" = None
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .collector import AnalyticsCollector
|
|
2
|
+
from .models import AnalyticsBatch, AnalyticsEvent, AnalyticsEventPayload, FeedbackData
|
|
3
|
+
from .worker_analytics import WorkerAnalytics
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AnalyticsCollector",
|
|
7
|
+
"AnalyticsBatch",
|
|
8
|
+
"AnalyticsEvent",
|
|
9
|
+
"AnalyticsEventPayload",
|
|
10
|
+
"FeedbackData",
|
|
11
|
+
"WorkerAnalytics",
|
|
12
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .models import AnalyticsBatch, AnalyticsEvent, AnalyticsEventPayload
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..registry import ConnectionRegistry
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnalyticsCollector:
|
|
20
|
+
"""
|
|
21
|
+
Collects analytics events, broadcasts to subscribers, and batches for webhook delivery.
|
|
22
|
+
|
|
23
|
+
Features:
|
|
24
|
+
- Real-time broadcast to analytics subscribers
|
|
25
|
+
- Batched webhook delivery (configurable size and interval)
|
|
26
|
+
- Background flush task for time-based batching
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
webhook_url: str,
|
|
32
|
+
registry: ConnectionRegistry,
|
|
33
|
+
subscriptions: dict[str, set[str]], # worker_id -> set of ws_ids
|
|
34
|
+
batch_size: int = 10,
|
|
35
|
+
flush_interval: float = 5.0,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._webhook_url = webhook_url
|
|
38
|
+
self._registry = registry
|
|
39
|
+
self._subscriptions = subscriptions
|
|
40
|
+
self._batch_size = batch_size
|
|
41
|
+
self._flush_interval = flush_interval
|
|
42
|
+
self._buffer: list[AnalyticsEventPayload] = []
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
self._flush_task: asyncio.Task[None] | None = None
|
|
45
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
46
|
+
self._running = False
|
|
47
|
+
|
|
48
|
+
async def start(self) -> None:
|
|
49
|
+
"""Start the background flush task and HTTP client."""
|
|
50
|
+
if self._running:
|
|
51
|
+
return
|
|
52
|
+
self._running = True
|
|
53
|
+
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
54
|
+
self._flush_task = asyncio.create_task(self._flush_loop())
|
|
55
|
+
|
|
56
|
+
async def stop(self) -> None:
|
|
57
|
+
"""Stop the background task and flush remaining events."""
|
|
58
|
+
self._running = False
|
|
59
|
+
if self._flush_task:
|
|
60
|
+
self._flush_task.cancel()
|
|
61
|
+
try:
|
|
62
|
+
await self._flush_task
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
pass
|
|
65
|
+
self._flush_task = None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Flush any remaining events
|
|
69
|
+
await self._flush()
|
|
70
|
+
finally:
|
|
71
|
+
# Always close HTTP client
|
|
72
|
+
if self._http_client:
|
|
73
|
+
await self._http_client.aclose()
|
|
74
|
+
self._http_client = None
|
|
75
|
+
|
|
76
|
+
async def track(
|
|
77
|
+
self,
|
|
78
|
+
event: str,
|
|
79
|
+
worker_id: str,
|
|
80
|
+
thread_id: str | None = None,
|
|
81
|
+
user_id: str | None = None,
|
|
82
|
+
run_id: str | None = None,
|
|
83
|
+
event_id: str | None = None,
|
|
84
|
+
data: dict[str, Any] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Track an analytics event.
|
|
88
|
+
|
|
89
|
+
Immediately broadcasts to analytics subscribers and adds to batch buffer.
|
|
90
|
+
"""
|
|
91
|
+
payload = AnalyticsEventPayload(
|
|
92
|
+
event=event,
|
|
93
|
+
timestamp=datetime.now(UTC),
|
|
94
|
+
worker_id=worker_id,
|
|
95
|
+
thread_id=thread_id,
|
|
96
|
+
user_id=user_id,
|
|
97
|
+
run_id=run_id,
|
|
98
|
+
event_id=event_id,
|
|
99
|
+
data=data,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Broadcast to analytics subscribers immediately
|
|
103
|
+
await self._broadcast(worker_id, payload)
|
|
104
|
+
|
|
105
|
+
# Add to batch buffer
|
|
106
|
+
async with self._lock:
|
|
107
|
+
self._buffer.append(payload)
|
|
108
|
+
if len(self._buffer) >= self._batch_size:
|
|
109
|
+
await self._flush_locked()
|
|
110
|
+
|
|
111
|
+
async def feedback(
|
|
112
|
+
self,
|
|
113
|
+
feedback_type: str,
|
|
114
|
+
target_type: str,
|
|
115
|
+
target_id: str,
|
|
116
|
+
worker_id: str,
|
|
117
|
+
thread_id: str | None = None,
|
|
118
|
+
user_id: str | None = None,
|
|
119
|
+
reason: str | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Record a feedback event (like or dislike)."""
|
|
122
|
+
event = AnalyticsEvent.FEEDBACK_LIKE if feedback_type == "like" else AnalyticsEvent.FEEDBACK_DISLIKE
|
|
123
|
+
await self.track(
|
|
124
|
+
event=event.value,
|
|
125
|
+
worker_id=worker_id,
|
|
126
|
+
thread_id=thread_id,
|
|
127
|
+
user_id=user_id,
|
|
128
|
+
data={
|
|
129
|
+
"target_type": target_type,
|
|
130
|
+
"target_id": target_id,
|
|
131
|
+
"reason": reason,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def _broadcast(self, worker_id: str, payload: AnalyticsEventPayload) -> None:
|
|
136
|
+
"""Broadcast an analytics event to all subscribers for a worker."""
|
|
137
|
+
from ..protocol.frames import S2C_AnalyticsEvent
|
|
138
|
+
|
|
139
|
+
subscriber_ws_ids = self._subscriptions.get(worker_id, set())
|
|
140
|
+
if not subscriber_ws_ids:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
message = S2C_AnalyticsEvent(
|
|
144
|
+
id=str(uuid4()),
|
|
145
|
+
payload=payload,
|
|
146
|
+
)
|
|
147
|
+
message_json = message.model_dump_json()
|
|
148
|
+
|
|
149
|
+
# Send to all subscribed connections
|
|
150
|
+
# subscriber_ws_ids contains the ws_id from Router, which we use to track subscriptions
|
|
151
|
+
# We need to send to all connections since we can't map ws_id back to ws objects
|
|
152
|
+
# The subscription dict tracks which ws_ids are subscribed, but we broadcast to all
|
|
153
|
+
# connections for the worker - this is acceptable for real-time analytics
|
|
154
|
+
connections = self._registry.get_connections(worker_id)
|
|
155
|
+
for ws in connections:
|
|
156
|
+
try:
|
|
157
|
+
await ws.send_text(message_json)
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.warning("Failed to send analytics event to subscriber")
|
|
160
|
+
|
|
161
|
+
async def _flush(self) -> None:
|
|
162
|
+
"""Flush the buffer, acquiring lock first."""
|
|
163
|
+
async with self._lock:
|
|
164
|
+
await self._flush_locked()
|
|
165
|
+
|
|
166
|
+
async def _flush_locked(self) -> None:
|
|
167
|
+
"""Flush the buffer (must be called with lock held)."""
|
|
168
|
+
if not self._buffer:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Group events by worker_id for separate batches
|
|
172
|
+
events_by_worker: dict[str, list[AnalyticsEventPayload]] = {}
|
|
173
|
+
for event in self._buffer:
|
|
174
|
+
if event.worker_id not in events_by_worker:
|
|
175
|
+
events_by_worker[event.worker_id] = []
|
|
176
|
+
events_by_worker[event.worker_id].append(event)
|
|
177
|
+
|
|
178
|
+
self._buffer.clear()
|
|
179
|
+
|
|
180
|
+
# Send batches per worker
|
|
181
|
+
for worker_id, events in events_by_worker.items():
|
|
182
|
+
batch = AnalyticsBatch(
|
|
183
|
+
batch_id=str(uuid4()),
|
|
184
|
+
worker_id=worker_id,
|
|
185
|
+
events=events,
|
|
186
|
+
sent_at=datetime.now(UTC),
|
|
187
|
+
)
|
|
188
|
+
await self._send_to_webhook(batch)
|
|
189
|
+
|
|
190
|
+
async def _send_to_webhook(self, batch: AnalyticsBatch) -> None:
|
|
191
|
+
"""Send a batch to the webhook endpoint (fire and forget with retry)."""
|
|
192
|
+
if not self._http_client:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
response = await self._http_client.post(
|
|
197
|
+
self._webhook_url,
|
|
198
|
+
json=batch.model_dump(mode="json"),
|
|
199
|
+
headers={"Content-Type": "application/json"},
|
|
200
|
+
)
|
|
201
|
+
if response.status_code >= 400:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"Analytics webhook returned %d: %s",
|
|
204
|
+
response.status_code,
|
|
205
|
+
response.text[:200],
|
|
206
|
+
)
|
|
207
|
+
except httpx.RequestError as e:
|
|
208
|
+
logger.warning("Failed to send analytics batch: %s", e)
|
|
209
|
+
|
|
210
|
+
async def _flush_loop(self) -> None:
|
|
211
|
+
"""Background task that flushes every flush_interval seconds."""
|
|
212
|
+
while self._running:
|
|
213
|
+
try:
|
|
214
|
+
await asyncio.sleep(self._flush_interval)
|
|
215
|
+
await self._flush()
|
|
216
|
+
except asyncio.CancelledError:
|
|
217
|
+
break
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.exception("Error in analytics flush loop: %s", e)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnalyticsEvent(StrEnum):
|
|
9
|
+
"""Built-in analytics event types."""
|
|
10
|
+
|
|
11
|
+
# Automatic events
|
|
12
|
+
THREAD_CREATED = "thread.created"
|
|
13
|
+
MESSAGE_C2S = "message.c2s"
|
|
14
|
+
MESSAGE_S2C = "message.s2c"
|
|
15
|
+
TOOL_CALLED = "tool.called"
|
|
16
|
+
ERROR_OCCURRED = "error.occurred"
|
|
17
|
+
|
|
18
|
+
# Feedback events
|
|
19
|
+
FEEDBACK_LIKE = "feedback.like"
|
|
20
|
+
FEEDBACK_DISLIKE = "feedback.dislike"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnalyticsEventPayload(BaseModel):
|
|
24
|
+
"""Payload for an analytics event."""
|
|
25
|
+
|
|
26
|
+
event: str # AnalyticsEvent value or custom string
|
|
27
|
+
timestamp: datetime
|
|
28
|
+
worker_id: str
|
|
29
|
+
thread_id: str | None = None
|
|
30
|
+
user_id: str | None = None
|
|
31
|
+
run_id: str | None = None
|
|
32
|
+
event_id: str | None = None
|
|
33
|
+
data: dict[str, Any] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FeedbackData(BaseModel):
|
|
37
|
+
"""Feedback targeting information."""
|
|
38
|
+
|
|
39
|
+
target_type: Literal["event", "run", "thread"]
|
|
40
|
+
target_id: str
|
|
41
|
+
reason: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnalyticsBatch(BaseModel):
|
|
45
|
+
"""Batch of analytics events for webhook delivery."""
|
|
46
|
+
|
|
47
|
+
batch_id: str
|
|
48
|
+
worker_id: str
|
|
49
|
+
events: list[AnalyticsEventPayload]
|
|
50
|
+
sent_at: datetime
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .collector import AnalyticsCollector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkerAnalytics:
|
|
10
|
+
"""
|
|
11
|
+
Handler API for analytics tracking.
|
|
12
|
+
|
|
13
|
+
Provides methods for tracking custom events and recording feedback.
|
|
14
|
+
Passed as the fourth parameter to handler functions.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
worker_id: str,
|
|
20
|
+
thread_id: str,
|
|
21
|
+
user_id: str | None,
|
|
22
|
+
run_id: str | None,
|
|
23
|
+
collector: AnalyticsCollector,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._worker_id = worker_id
|
|
26
|
+
self._thread_id = thread_id
|
|
27
|
+
self._user_id = user_id
|
|
28
|
+
self._run_id = run_id
|
|
29
|
+
self._collector = collector
|
|
30
|
+
|
|
31
|
+
async def track(self, event: str, data: dict[str, Any] | None = None) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Track a custom analytics event.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
event: Event name (e.g., "llm.called", "search.performed")
|
|
37
|
+
data: Optional event-specific data
|
|
38
|
+
"""
|
|
39
|
+
await self._collector.track(
|
|
40
|
+
event=event,
|
|
41
|
+
worker_id=self._worker_id,
|
|
42
|
+
thread_id=self._thread_id,
|
|
43
|
+
user_id=self._user_id,
|
|
44
|
+
run_id=self._run_id,
|
|
45
|
+
data=data,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def like(
|
|
49
|
+
self,
|
|
50
|
+
target_type: str,
|
|
51
|
+
target_id: str,
|
|
52
|
+
reason: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Record a like (positive feedback).
|
|
56
|
+
|
|
57
|
+
Useful for external sources like WhatsApp where users can't
|
|
58
|
+
interact with the dashboard directly.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
target_type: Type of target ("event", "run", or "thread")
|
|
62
|
+
target_id: ID of the target
|
|
63
|
+
reason: Optional reason for the feedback
|
|
64
|
+
"""
|
|
65
|
+
await self._collector.feedback(
|
|
66
|
+
feedback_type="like",
|
|
67
|
+
target_type=target_type,
|
|
68
|
+
target_id=target_id,
|
|
69
|
+
worker_id=self._worker_id,
|
|
70
|
+
thread_id=self._thread_id,
|
|
71
|
+
user_id=self._user_id,
|
|
72
|
+
reason=reason,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def dislike(
|
|
76
|
+
self,
|
|
77
|
+
target_type: str,
|
|
78
|
+
target_id: str,
|
|
79
|
+
reason: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Record a dislike (negative feedback).
|
|
83
|
+
|
|
84
|
+
Useful for external sources like WhatsApp where users can't
|
|
85
|
+
interact with the dashboard directly.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
target_type: Type of target ("event", "run", or "thread")
|
|
89
|
+
target_id: ID of the target
|
|
90
|
+
reason: Optional reason for the feedback
|
|
91
|
+
"""
|
|
92
|
+
await self._collector.feedback(
|
|
93
|
+
feedback_type="dislike",
|
|
94
|
+
target_type=target_type,
|
|
95
|
+
target_id=target_id,
|
|
96
|
+
worker_id=self._worker_id,
|
|
97
|
+
thread_id=self._thread_id,
|
|
98
|
+
user_id=self._user_id,
|
|
99
|
+
reason=reason,
|
|
100
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .broadcaster import SettingsBroadcaster
|
|
2
|
+
from .models import SettingsField, SettingsFieldType, SettingsSchema, SettingsSelectOption
|
|
3
|
+
from .worker_settings import WorkerSettings
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"SettingsBroadcaster",
|
|
7
|
+
"SettingsField",
|
|
8
|
+
"SettingsFieldType",
|
|
9
|
+
"SettingsSchema",
|
|
10
|
+
"SettingsSelectOption",
|
|
11
|
+
"WorkerSettings",
|
|
12
|
+
]
|