watchup 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.
- watchup/__init__.py +28 -0
- watchup/batcher.py +105 -0
- watchup/client.py +249 -0
- watchup/middleware.py +255 -0
- watchup/transport.py +57 -0
- watchup/types.py +116 -0
- watchup-0.1.0.dist-info/METADATA +241 -0
- watchup-0.1.0.dist-info/RECORD +10 -0
- watchup-0.1.0.dist-info/WHEEL +5 -0
- watchup-0.1.0.dist-info/top_level.txt +1 -0
watchup/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup — application monitoring SDK for Python
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from watchup import Watchup
|
|
7
|
+
|
|
8
|
+
watchup = Watchup(api_key="wup_live_xxxxxxxxxxxx")
|
|
9
|
+
|
|
10
|
+
# Flask
|
|
11
|
+
watchup.init_app(app)
|
|
12
|
+
|
|
13
|
+
# Manual error capture
|
|
14
|
+
watchup.capture_error(exc, route="job.process_order")
|
|
15
|
+
|
|
16
|
+
# Custom events
|
|
17
|
+
watchup.track("user.signed_up", {"plan": "pro"})
|
|
18
|
+
|
|
19
|
+
# Trace any operation
|
|
20
|
+
end = watchup.start_trace("db.query")
|
|
21
|
+
end()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .client import Watchup
|
|
25
|
+
from .middleware import WatchupDjangoMiddleware, WatchupWSGI
|
|
26
|
+
|
|
27
|
+
__all__ = ["Watchup", "WatchupDjangoMiddleware", "WatchupWSGI"]
|
|
28
|
+
__version__ = "0.1.0"
|
watchup/batcher.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup · batcher
|
|
3
|
+
|
|
4
|
+
Accumulates traces/errors/events and flushes them in batches via a
|
|
5
|
+
background daemon thread. The thread is daemonised so it never prevents
|
|
6
|
+
the interpreter from exiting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import threading
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from .transport import Transport
|
|
16
|
+
from .types import ErrorPayload, EventPayload, IngestBatch, TracePayload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Batcher:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
transport: Transport,
|
|
23
|
+
flush_interval: float,
|
|
24
|
+
max_batch_size: int,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._transport = transport
|
|
27
|
+
self._flush_interval = flush_interval
|
|
28
|
+
self._max_batch_size = max_batch_size
|
|
29
|
+
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._traces: List[TracePayload] = []
|
|
32
|
+
self._errors: List[ErrorPayload] = []
|
|
33
|
+
self._events: List[EventPayload] = []
|
|
34
|
+
|
|
35
|
+
self._timer: threading.Timer | None = None
|
|
36
|
+
self._started = False
|
|
37
|
+
|
|
38
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def start(self) -> None:
|
|
41
|
+
if self._started:
|
|
42
|
+
return
|
|
43
|
+
self._started = True
|
|
44
|
+
self._schedule()
|
|
45
|
+
atexit.register(self.flush)
|
|
46
|
+
|
|
47
|
+
def stop(self) -> None:
|
|
48
|
+
self._started = False
|
|
49
|
+
if self._timer is not None:
|
|
50
|
+
self._timer.cancel()
|
|
51
|
+
self._timer = None
|
|
52
|
+
|
|
53
|
+
def _schedule(self) -> None:
|
|
54
|
+
if not self._started:
|
|
55
|
+
return
|
|
56
|
+
self._timer = threading.Timer(self._flush_interval, self._tick)
|
|
57
|
+
self._timer.daemon = True
|
|
58
|
+
self._timer.start()
|
|
59
|
+
|
|
60
|
+
def _tick(self) -> None:
|
|
61
|
+
self.flush()
|
|
62
|
+
self._schedule()
|
|
63
|
+
|
|
64
|
+
# ── Enqueue ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def add_trace(self, trace: TracePayload) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
self._traces.append(trace)
|
|
69
|
+
should_flush = len(self._traces) >= self._max_batch_size
|
|
70
|
+
if should_flush:
|
|
71
|
+
self.flush()
|
|
72
|
+
|
|
73
|
+
def add_error(self, error: ErrorPayload) -> None:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._errors.append(error)
|
|
76
|
+
# Errors are higher priority — flush at half capacity
|
|
77
|
+
should_flush = len(self._errors) >= max(1, self._max_batch_size // 2)
|
|
78
|
+
if should_flush:
|
|
79
|
+
self.flush()
|
|
80
|
+
|
|
81
|
+
def add_event(self, event: EventPayload) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._events.append(event)
|
|
84
|
+
should_flush = len(self._events) >= self._max_batch_size
|
|
85
|
+
if should_flush:
|
|
86
|
+
self.flush()
|
|
87
|
+
|
|
88
|
+
# ── Flush ─────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def flush(self) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
traces = self._traces[:]
|
|
93
|
+
errors = self._errors[:]
|
|
94
|
+
events = self._events[:]
|
|
95
|
+
self._traces.clear()
|
|
96
|
+
self._errors.clear()
|
|
97
|
+
self._events.clear()
|
|
98
|
+
|
|
99
|
+
if not traces and not errors and not events:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
batch = IngestBatch(traces=traces, errors=errors, events=events)
|
|
103
|
+
# Run in a daemon thread so it never blocks the caller
|
|
104
|
+
t = threading.Thread(target=self._transport.send, args=(batch,), daemon=True)
|
|
105
|
+
t.start()
|
watchup/client.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup · main client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import traceback
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable, Dict, Literal, Optional
|
|
11
|
+
|
|
12
|
+
from .batcher import Batcher
|
|
13
|
+
from .transport import Transport
|
|
14
|
+
from .types import ErrorPayload, EventPayload, TracePayload, WatchupUser
|
|
15
|
+
|
|
16
|
+
_DEFAULTS = {
|
|
17
|
+
"base_url": "https://api.watchup.site",
|
|
18
|
+
"flush_interval": 5.0,
|
|
19
|
+
"max_batch_size": 100,
|
|
20
|
+
"debug": False,
|
|
21
|
+
"sample_rate": 1.0,
|
|
22
|
+
"release": None,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Watchup:
|
|
31
|
+
"""
|
|
32
|
+
Watchup monitoring client.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
api_key : str
|
|
37
|
+
Your project API key (``wup_live_…``).
|
|
38
|
+
base_url : str
|
|
39
|
+
Override for self-hosted deployments.
|
|
40
|
+
Default: ``https://api.watchup.site``.
|
|
41
|
+
environment : str
|
|
42
|
+
Runtime label attached to every payload.
|
|
43
|
+
Default: ``WATCHUP_ENV`` env var, or ``"production"``.
|
|
44
|
+
release : str, optional
|
|
45
|
+
App version / git SHA for deploy correlation.
|
|
46
|
+
flush_interval : float
|
|
47
|
+
Seconds between automatic queue flushes. Default: ``5.0``.
|
|
48
|
+
max_batch_size : int
|
|
49
|
+
Item count per type that triggers an immediate flush. Default: ``100``.
|
|
50
|
+
sample_rate : float
|
|
51
|
+
Fraction of requests to trace (0–1). Default: ``1.0``.
|
|
52
|
+
debug : bool
|
|
53
|
+
Log SDK warnings to stderr. Default: ``False``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str,
|
|
59
|
+
*,
|
|
60
|
+
base_url: str = _DEFAULTS["base_url"], # type: ignore[assignment]
|
|
61
|
+
environment: Optional[str] = None,
|
|
62
|
+
release: Optional[str] = None,
|
|
63
|
+
flush_interval: float = _DEFAULTS["flush_interval"], # type: ignore[assignment]
|
|
64
|
+
max_batch_size: int = _DEFAULTS["max_batch_size"], # type: ignore[assignment]
|
|
65
|
+
sample_rate: float = _DEFAULTS["sample_rate"], # type: ignore[assignment]
|
|
66
|
+
debug: bool = _DEFAULTS["debug"], # type: ignore[assignment]
|
|
67
|
+
) -> None:
|
|
68
|
+
if not api_key:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"[watchup] api_key is required. "
|
|
71
|
+
"Find it in your Watchup dashboard → Project Settings → API Keys."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.environment: str = (
|
|
75
|
+
environment
|
|
76
|
+
or os.environ.get("WATCHUP_ENV")
|
|
77
|
+
or os.environ.get("WATCHUP_ENVIRONMENT")
|
|
78
|
+
or "production"
|
|
79
|
+
)
|
|
80
|
+
self.release: Optional[str] = release
|
|
81
|
+
self._sample_rate = sample_rate
|
|
82
|
+
|
|
83
|
+
transport = Transport(base_url, api_key, debug)
|
|
84
|
+
self._batcher = Batcher(transport, flush_interval, max_batch_size)
|
|
85
|
+
self._batcher.start()
|
|
86
|
+
|
|
87
|
+
self._user: Optional[WatchupUser] = None
|
|
88
|
+
|
|
89
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def _user_dict(self) -> Optional[Dict[str, Any]]:
|
|
92
|
+
return self._user.to_dict() if self._user else None
|
|
93
|
+
|
|
94
|
+
# ── User identification ───────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def set_user(
|
|
97
|
+
self,
|
|
98
|
+
id: str | int,
|
|
99
|
+
*,
|
|
100
|
+
email: Optional[str] = None,
|
|
101
|
+
name: Optional[str] = None,
|
|
102
|
+
**extra: Any,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Attach a user to all subsequent errors and traces.
|
|
106
|
+
|
|
107
|
+
Example::
|
|
108
|
+
|
|
109
|
+
watchup.set_user("usr_42", email="alice@example.com", name="Alice", plan="pro")
|
|
110
|
+
"""
|
|
111
|
+
self._user = WatchupUser(id=id, email=email, name=name, extra=extra or None)
|
|
112
|
+
|
|
113
|
+
def clear_user(self) -> None:
|
|
114
|
+
"""Remove the current user context."""
|
|
115
|
+
self._user = None
|
|
116
|
+
|
|
117
|
+
# ── Flask integration ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def init_app(self, app: Any) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Register Watchup on a Flask application instance.
|
|
122
|
+
|
|
123
|
+
Example::
|
|
124
|
+
|
|
125
|
+
app = Flask(__name__)
|
|
126
|
+
watchup.init_app(app)
|
|
127
|
+
|
|
128
|
+
Registers ``before_request``, ``after_request``, and ``errorhandler``
|
|
129
|
+
hooks automatically.
|
|
130
|
+
"""
|
|
131
|
+
from .middleware import _flask_init_app
|
|
132
|
+
_flask_init_app(self, app)
|
|
133
|
+
|
|
134
|
+
# ── Manual tracking ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def track(self, name: str, properties: Optional[Dict[str, Any]] = None) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Send a custom analytics event.
|
|
139
|
+
|
|
140
|
+
Example::
|
|
141
|
+
|
|
142
|
+
watchup.track("user.signed_up", {"plan": "pro", "source": "invite"})
|
|
143
|
+
watchup.track("order.placed", {"amount": 4999, "currency": "NGN"})
|
|
144
|
+
"""
|
|
145
|
+
if not name:
|
|
146
|
+
return
|
|
147
|
+
event = EventPayload(
|
|
148
|
+
name=name,
|
|
149
|
+
occurred_at=_now(),
|
|
150
|
+
properties=properties or None,
|
|
151
|
+
)
|
|
152
|
+
self._batcher.add_event(event)
|
|
153
|
+
|
|
154
|
+
def capture_error(
|
|
155
|
+
self,
|
|
156
|
+
error: BaseException | str,
|
|
157
|
+
*,
|
|
158
|
+
route: Optional[str] = None,
|
|
159
|
+
level: Literal["debug", "info", "warning", "error", "fatal"] = "error",
|
|
160
|
+
**context: Any,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Manually capture an error — use this for background jobs, queue
|
|
164
|
+
consumers, cron tasks, or anywhere outside a request context.
|
|
165
|
+
|
|
166
|
+
Example::
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
process_order(order_id)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
watchup.capture_error(exc, route="job.process_order", order_id=order_id)
|
|
172
|
+
"""
|
|
173
|
+
if isinstance(error, BaseException):
|
|
174
|
+
message = str(error)
|
|
175
|
+
stack = "".join(
|
|
176
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
message = str(error)
|
|
180
|
+
stack = None
|
|
181
|
+
|
|
182
|
+
payload = ErrorPayload(
|
|
183
|
+
message=message,
|
|
184
|
+
level=level,
|
|
185
|
+
route=route,
|
|
186
|
+
stack=stack,
|
|
187
|
+
context=context or None,
|
|
188
|
+
timestamp=_now(),
|
|
189
|
+
environment=self.environment,
|
|
190
|
+
release=self.release,
|
|
191
|
+
user=self._user_dict(),
|
|
192
|
+
)
|
|
193
|
+
self._batcher.add_error(payload)
|
|
194
|
+
|
|
195
|
+
def start_trace(self, span: str) -> Callable[..., None]:
|
|
196
|
+
"""
|
|
197
|
+
Time a non-HTTP operation and record it as a trace.
|
|
198
|
+
|
|
199
|
+
Returns an ``end()`` callable. Call it when the operation finishes.
|
|
200
|
+
|
|
201
|
+
Example::
|
|
202
|
+
|
|
203
|
+
end = watchup.start_trace("db.query_users")
|
|
204
|
+
try:
|
|
205
|
+
rows = db.query(sql)
|
|
206
|
+
end() # status defaults to "ok"
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
end(status="err")
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
The ``end()`` callable accepts optional keyword arguments:
|
|
212
|
+
- ``status``: ``"ok"`` (default), ``"warn"``, or ``"err"``
|
|
213
|
+
- ``meta``: arbitrary dict of extra context
|
|
214
|
+
"""
|
|
215
|
+
import time as _time
|
|
216
|
+
start = _time.time()
|
|
217
|
+
|
|
218
|
+
def end(
|
|
219
|
+
*,
|
|
220
|
+
status: Literal["ok", "warn", "err"] = "ok",
|
|
221
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
ms = (_time.time() - start) * 1000
|
|
224
|
+
status_code = 500 if status == "err" else 400 if status == "warn" else 200
|
|
225
|
+
trace = TracePayload(
|
|
226
|
+
span=span,
|
|
227
|
+
ms=ms,
|
|
228
|
+
status_code=status_code,
|
|
229
|
+
status=status,
|
|
230
|
+
timestamp=_now(),
|
|
231
|
+
environment=self.environment,
|
|
232
|
+
release=self.release,
|
|
233
|
+
meta=meta,
|
|
234
|
+
user=self._user_dict(),
|
|
235
|
+
)
|
|
236
|
+
self._batcher.add_trace(trace)
|
|
237
|
+
|
|
238
|
+
return end
|
|
239
|
+
|
|
240
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def flush(self) -> None:
|
|
243
|
+
"""Immediately flush all queued items."""
|
|
244
|
+
self._batcher.flush()
|
|
245
|
+
|
|
246
|
+
def shutdown(self) -> None:
|
|
247
|
+
"""Stop the background flush timer and send remaining items."""
|
|
248
|
+
self._batcher.stop()
|
|
249
|
+
self._batcher.flush()
|
watchup/middleware.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup · middleware integrations
|
|
3
|
+
|
|
4
|
+
Flask
|
|
5
|
+
-----
|
|
6
|
+
from watchup import Watchup
|
|
7
|
+
watchup = Watchup(api_key="wup_live_...")
|
|
8
|
+
watchup.init_app(app)
|
|
9
|
+
|
|
10
|
+
Django
|
|
11
|
+
------
|
|
12
|
+
# settings.py
|
|
13
|
+
MIDDLEWARE = [
|
|
14
|
+
"watchup.middleware.WatchupDjangoMiddleware",
|
|
15
|
+
...
|
|
16
|
+
]
|
|
17
|
+
WATCHUP_API_KEY = "wup_live_..."
|
|
18
|
+
|
|
19
|
+
WSGI (framework-agnostic)
|
|
20
|
+
------
|
|
21
|
+
app.wsgi_app = WatchupWSGI(app.wsgi_app, watchup)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import traceback
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
|
|
31
|
+
|
|
32
|
+
from .types import ErrorPayload, TracePayload
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .client import Watchup
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _now() -> str:
|
|
41
|
+
return datetime.now(timezone.utc).isoformat()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _trace_status(status_code: int) -> str:
|
|
45
|
+
if status_code >= 500:
|
|
46
|
+
return "err"
|
|
47
|
+
if status_code >= 400:
|
|
48
|
+
return "warn"
|
|
49
|
+
return "ok"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalise_path(path: str) -> str:
|
|
53
|
+
import re
|
|
54
|
+
path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/:id", path, flags=re.I)
|
|
55
|
+
path = re.sub(r"/\d+", "/:id", path)
|
|
56
|
+
return path.rstrip("/") or "/"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Flask integration ─────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _flask_init_app(watchup_client: "Watchup", app: Any) -> None:
|
|
62
|
+
"""Register Watchup before/after/teardown hooks on a Flask app."""
|
|
63
|
+
try:
|
|
64
|
+
from flask import g, request
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"Flask is not installed. Run: pip install flask"
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
@app.before_request
|
|
71
|
+
def _before() -> None:
|
|
72
|
+
g._watchup_start = time.time()
|
|
73
|
+
|
|
74
|
+
@app.after_request
|
|
75
|
+
def _after(response: Any) -> Any:
|
|
76
|
+
start = getattr(g, "_watchup_start", None)
|
|
77
|
+
if start is None:
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
ms = (time.time() - start) * 1000
|
|
81
|
+
route = request.endpoint or _normalise_path(request.path)
|
|
82
|
+
span = f"{request.method} {route}"
|
|
83
|
+
|
|
84
|
+
trace = TracePayload(
|
|
85
|
+
span=span,
|
|
86
|
+
ms=ms,
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
status=_trace_status(response.status_code), # type: ignore[arg-type]
|
|
89
|
+
timestamp=_now(),
|
|
90
|
+
environment=watchup_client.environment,
|
|
91
|
+
release=watchup_client.release,
|
|
92
|
+
user=watchup_client._user_dict(),
|
|
93
|
+
)
|
|
94
|
+
watchup_client._batcher.add_trace(trace)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
@app.errorhandler(Exception)
|
|
98
|
+
def _on_error(exc: Exception) -> Any:
|
|
99
|
+
error = ErrorPayload(
|
|
100
|
+
message=str(exc),
|
|
101
|
+
level="error",
|
|
102
|
+
route=f"{request.method} {_normalise_path(request.path)}",
|
|
103
|
+
stack=traceback.format_exc(),
|
|
104
|
+
context={
|
|
105
|
+
"method": request.method,
|
|
106
|
+
"url": request.url,
|
|
107
|
+
},
|
|
108
|
+
timestamp=_now(),
|
|
109
|
+
environment=watchup_client.environment,
|
|
110
|
+
release=watchup_client.release,
|
|
111
|
+
user=watchup_client._user_dict(),
|
|
112
|
+
)
|
|
113
|
+
watchup_client._batcher.add_error(error)
|
|
114
|
+
raise exc # re-raise so Flask still handles it normally
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Django middleware class ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
class WatchupDjangoMiddleware:
|
|
120
|
+
"""
|
|
121
|
+
Django middleware. Add to MIDDLEWARE in settings.py:
|
|
122
|
+
|
|
123
|
+
MIDDLEWARE = [
|
|
124
|
+
"watchup.middleware.WatchupDjangoMiddleware",
|
|
125
|
+
...
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
Requires WATCHUP_API_KEY in Django settings (or the WATCHUP_API_KEY env var).
|
|
129
|
+
Optionally set WATCHUP_ENVIRONMENT and WATCHUP_RELEASE in Django settings.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
_instance: Optional["Watchup"] = None
|
|
133
|
+
|
|
134
|
+
def __init__(self, get_response: Callable) -> None:
|
|
135
|
+
self.get_response = get_response
|
|
136
|
+
if WatchupDjangoMiddleware._instance is None:
|
|
137
|
+
from django.conf import settings as django_settings
|
|
138
|
+
from .client import Watchup
|
|
139
|
+
|
|
140
|
+
api_key = (
|
|
141
|
+
getattr(django_settings, "WATCHUP_API_KEY", None)
|
|
142
|
+
or os.environ.get("WATCHUP_API_KEY", "")
|
|
143
|
+
)
|
|
144
|
+
environment = (
|
|
145
|
+
getattr(django_settings, "WATCHUP_ENVIRONMENT", None)
|
|
146
|
+
or os.environ.get("WATCHUP_ENVIRONMENT", "production")
|
|
147
|
+
)
|
|
148
|
+
release = getattr(django_settings, "WATCHUP_RELEASE", None)
|
|
149
|
+
WatchupDjangoMiddleware._instance = Watchup(
|
|
150
|
+
api_key=api_key,
|
|
151
|
+
environment=environment,
|
|
152
|
+
release=release,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def __call__(self, request: Any) -> Any:
|
|
156
|
+
start = time.time()
|
|
157
|
+
try:
|
|
158
|
+
response = self.get_response(request)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
client = WatchupDjangoMiddleware._instance
|
|
161
|
+
if client:
|
|
162
|
+
error = ErrorPayload(
|
|
163
|
+
message=str(exc),
|
|
164
|
+
level="error",
|
|
165
|
+
route=f"{request.method} {_normalise_path(request.path)}",
|
|
166
|
+
stack=traceback.format_exc(),
|
|
167
|
+
context={"method": request.method, "path": request.path},
|
|
168
|
+
timestamp=_now(),
|
|
169
|
+
environment=client.environment,
|
|
170
|
+
release=client.release,
|
|
171
|
+
user=client._user_dict(),
|
|
172
|
+
)
|
|
173
|
+
client._batcher.add_error(error)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
ms = (time.time() - start) * 1000
|
|
177
|
+
client = WatchupDjangoMiddleware._instance
|
|
178
|
+
if client:
|
|
179
|
+
route = getattr(getattr(request, "resolver_match", None), "route", None)
|
|
180
|
+
span = f"{request.method} {route or _normalise_path(request.path)}"
|
|
181
|
+
trace = TracePayload(
|
|
182
|
+
span=span,
|
|
183
|
+
ms=ms,
|
|
184
|
+
status_code=response.status_code,
|
|
185
|
+
status=_trace_status(response.status_code), # type: ignore[arg-type]
|
|
186
|
+
timestamp=_now(),
|
|
187
|
+
environment=client.environment,
|
|
188
|
+
release=client.release,
|
|
189
|
+
user=client._user_dict(),
|
|
190
|
+
)
|
|
191
|
+
client._batcher.add_trace(trace)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── WSGI middleware (framework-agnostic) ──────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
class WatchupWSGI:
|
|
198
|
+
"""
|
|
199
|
+
WSGI middleware wrapper — works with any WSGI framework.
|
|
200
|
+
|
|
201
|
+
Example (Flask):
|
|
202
|
+
app.wsgi_app = WatchupWSGI(app.wsgi_app, watchup)
|
|
203
|
+
|
|
204
|
+
Example (bare WSGI):
|
|
205
|
+
application = WatchupWSGI(application, watchup)
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, wsgi_app: Callable, watchup_client: "Watchup") -> None:
|
|
209
|
+
self._app = wsgi_app
|
|
210
|
+
self._client = watchup_client
|
|
211
|
+
|
|
212
|
+
def __call__(self, environ: Dict, start_response: Callable) -> Any:
|
|
213
|
+
start = time.time()
|
|
214
|
+
status_holder: list = []
|
|
215
|
+
|
|
216
|
+
def _start_response(status: str, headers: Any, exc_info: Any = None) -> Any:
|
|
217
|
+
status_holder.append(int(status.split(" ", 1)[0]))
|
|
218
|
+
return start_response(status, headers, exc_info)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
result = self._app(environ, _start_response)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
ms = (time.time() - start) * 1000
|
|
224
|
+
path = environ.get("PATH_INFO", "/")
|
|
225
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
226
|
+
error = ErrorPayload(
|
|
227
|
+
message=str(exc),
|
|
228
|
+
level="error",
|
|
229
|
+
route=f"{method} {_normalise_path(path)}",
|
|
230
|
+
stack=traceback.format_exc(),
|
|
231
|
+
context={"method": method, "path": path},
|
|
232
|
+
timestamp=_now(),
|
|
233
|
+
environment=self._client.environment,
|
|
234
|
+
release=self._client.release,
|
|
235
|
+
user=self._client._user_dict(),
|
|
236
|
+
)
|
|
237
|
+
self._client._batcher.add_error(error)
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
ms = (time.time() - start) * 1000
|
|
241
|
+
status_code = status_holder[0] if status_holder else 200
|
|
242
|
+
path = environ.get("PATH_INFO", "/")
|
|
243
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
244
|
+
trace = TracePayload(
|
|
245
|
+
span=f"{method} {_normalise_path(path)}",
|
|
246
|
+
ms=ms,
|
|
247
|
+
status_code=status_code,
|
|
248
|
+
status=_trace_status(status_code), # type: ignore[arg-type]
|
|
249
|
+
timestamp=_now(),
|
|
250
|
+
environment=self._client.environment,
|
|
251
|
+
release=self._client.release,
|
|
252
|
+
user=self._client._user_dict(),
|
|
253
|
+
)
|
|
254
|
+
self._client._batcher.add_trace(trace)
|
|
255
|
+
return result
|
watchup/transport.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup · HTTP transport
|
|
3
|
+
|
|
4
|
+
Posts ingest batches to the Watchup API. All failures are swallowed —
|
|
5
|
+
the SDK must never crash the host application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Dict
|
|
15
|
+
|
|
16
|
+
from .types import IngestBatch
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("watchup")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport:
|
|
22
|
+
def __init__(self, base_url: str, api_key: str, debug: bool = False) -> None:
|
|
23
|
+
self._url = f"{base_url.rstrip('/')}/api/v1/ingest/batch"
|
|
24
|
+
self._headers = {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"X-Api-Key": api_key,
|
|
27
|
+
"User-Agent": "watchup-python",
|
|
28
|
+
}
|
|
29
|
+
self._debug = debug
|
|
30
|
+
|
|
31
|
+
def send(self, batch: IngestBatch) -> None:
|
|
32
|
+
payload: Dict[str, Any] = {}
|
|
33
|
+
if batch.traces:
|
|
34
|
+
payload["traces"] = [t.to_dict() for t in batch.traces]
|
|
35
|
+
if batch.errors:
|
|
36
|
+
payload["errors"] = [e.to_dict() for e in batch.errors]
|
|
37
|
+
if batch.events:
|
|
38
|
+
payload["events"] = [ev.to_dict() for ev in batch.events]
|
|
39
|
+
|
|
40
|
+
if not payload:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
body = json.dumps(payload).encode()
|
|
45
|
+
req = urllib.request.Request(
|
|
46
|
+
self._url,
|
|
47
|
+
data=body,
|
|
48
|
+
headers=self._headers,
|
|
49
|
+
method="POST",
|
|
50
|
+
)
|
|
51
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
52
|
+
if self._debug and resp.status >= 400:
|
|
53
|
+
log.warning("[watchup] ingest %s", resp.status)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
if self._debug:
|
|
56
|
+
log.warning("[watchup] send failed: %s", exc)
|
|
57
|
+
# Intentionally no re-raise.
|
watchup/types.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchup · payload types
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── User ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class WatchupUser:
|
|
15
|
+
id: str | int
|
|
16
|
+
email: Optional[str] = None
|
|
17
|
+
name: Optional[str] = None
|
|
18
|
+
extra: Optional[Dict[str, Any]] = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
21
|
+
d: Dict[str, Any] = {"id": self.id}
|
|
22
|
+
if self.email is not None:
|
|
23
|
+
d["email"] = self.email
|
|
24
|
+
if self.name is not None:
|
|
25
|
+
d["name"] = self.name
|
|
26
|
+
if self.extra:
|
|
27
|
+
d.update(self.extra)
|
|
28
|
+
return d
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Ingest payloads ───────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TracePayload:
|
|
35
|
+
span: str
|
|
36
|
+
ms: float
|
|
37
|
+
status_code: int
|
|
38
|
+
status: Literal["ok", "warn", "err"]
|
|
39
|
+
timestamp: str
|
|
40
|
+
environment: Optional[str] = None
|
|
41
|
+
release: Optional[str] = None
|
|
42
|
+
meta: Optional[Dict[str, Any]] = None
|
|
43
|
+
user: Optional[Dict[str, Any]] = None
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
46
|
+
d: Dict[str, Any] = {
|
|
47
|
+
"span": self.span,
|
|
48
|
+
"ms": round(self.ms, 2),
|
|
49
|
+
"status_code": self.status_code,
|
|
50
|
+
"status": self.status,
|
|
51
|
+
"timestamp": self.timestamp,
|
|
52
|
+
}
|
|
53
|
+
if self.environment is not None:
|
|
54
|
+
d["environment"] = self.environment
|
|
55
|
+
if self.release is not None:
|
|
56
|
+
d["release"] = self.release
|
|
57
|
+
if self.meta:
|
|
58
|
+
d["meta"] = self.meta
|
|
59
|
+
if self.user:
|
|
60
|
+
d["user"] = self.user
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ErrorPayload:
|
|
66
|
+
message: str
|
|
67
|
+
level: Literal["debug", "info", "warning", "error", "fatal"]
|
|
68
|
+
timestamp: str
|
|
69
|
+
route: Optional[str] = None
|
|
70
|
+
stack: Optional[str] = None
|
|
71
|
+
context: Optional[Dict[str, Any]] = None
|
|
72
|
+
environment: Optional[str] = None
|
|
73
|
+
release: Optional[str] = None
|
|
74
|
+
user: Optional[Dict[str, Any]] = None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
77
|
+
d: Dict[str, Any] = {
|
|
78
|
+
"message": self.message,
|
|
79
|
+
"level": self.level,
|
|
80
|
+
"timestamp": self.timestamp,
|
|
81
|
+
}
|
|
82
|
+
if self.route is not None:
|
|
83
|
+
d["route"] = self.route
|
|
84
|
+
if self.stack is not None:
|
|
85
|
+
d["stack"] = self.stack
|
|
86
|
+
if self.context:
|
|
87
|
+
d["context"] = self.context
|
|
88
|
+
if self.environment is not None:
|
|
89
|
+
d["environment"] = self.environment
|
|
90
|
+
if self.release is not None:
|
|
91
|
+
d["release"] = self.release
|
|
92
|
+
if self.user:
|
|
93
|
+
d["user"] = self.user
|
|
94
|
+
return d
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class EventPayload:
|
|
99
|
+
name: str
|
|
100
|
+
occurred_at: str
|
|
101
|
+
properties: Optional[Dict[str, Any]] = None
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
104
|
+
d: Dict[str, Any] = {"name": self.name, "occurred_at": self.occurred_at}
|
|
105
|
+
if self.properties:
|
|
106
|
+
d["properties"] = self.properties
|
|
107
|
+
return d
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Batch ─────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class IngestBatch:
|
|
114
|
+
traces: List[TracePayload] = field(default_factory=list)
|
|
115
|
+
errors: List[ErrorPayload] = field(default_factory=list)
|
|
116
|
+
events: List[EventPayload] = field(default_factory=list)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Watchup SDK for Python — error tracking, request tracing, and custom analytics
|
|
5
|
+
Author: Watchup Ltd
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://watchup.site
|
|
8
|
+
Project-URL: Documentation, https://watchup.site/docs/sdks/python
|
|
9
|
+
Project-URL: Repository, https://github.com/watchupltd/watchup-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/watchupltd/watchup-sdk/issues
|
|
11
|
+
Keywords: watchup,monitoring,observability,apm,tracing,error-tracking,flask,django,fastapi
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: flask
|
|
25
|
+
Requires-Dist: flask>=2.0; extra == "flask"
|
|
26
|
+
Provides-Extra: django
|
|
27
|
+
Requires-Dist: django>=3.2; extra == "django"
|
|
28
|
+
Provides-Extra: fastapi
|
|
29
|
+
Requires-Dist: fastapi>=0.95; extra == "fastapi"
|
|
30
|
+
Requires-Dist: starlette>=0.27; extra == "fastapi"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
33
|
+
Requires-Dist: flask>=2.0; extra == "dev"
|
|
34
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
35
|
+
|
|
36
|
+
# watchup
|
|
37
|
+
|
|
38
|
+
Official Python SDK for [Watchup](https://watchup.site) — error tracking, request tracing, and custom analytics for Python web applications.
|
|
39
|
+
|
|
40
|
+
Works with **Flask**, **Django**, **FastAPI**, and any **WSGI** framework. Zero required dependencies — uses the Python standard library only.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install watchup
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.9+.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from watchup import Watchup
|
|
58
|
+
|
|
59
|
+
watchup = Watchup(
|
|
60
|
+
api_key="wup_live_xxxxxxxxxxxx", # Dashboard → Project Settings → API Keys
|
|
61
|
+
environment="production",
|
|
62
|
+
release="v1.2.3", # optional: git SHA or version tag
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Flask
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from flask import Flask
|
|
72
|
+
from watchup import Watchup
|
|
73
|
+
|
|
74
|
+
watchup = Watchup(api_key="wup_live_xxxxxxxxxxxx")
|
|
75
|
+
app = Flask(__name__)
|
|
76
|
+
|
|
77
|
+
watchup.init_app(app) # registers before_request, after_request, errorhandler hooks
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`init_app` wires up:
|
|
81
|
+
|
|
82
|
+
- **Request tracing** — every request is recorded with method, route, status code, and duration
|
|
83
|
+
- **Error capture** — unhandled exceptions are reported with stack trace and request context
|
|
84
|
+
- **Transparent re-raise** — errors still propagate to your own error handlers
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Django
|
|
89
|
+
|
|
90
|
+
Add `WatchupDjangoMiddleware` to your `MIDDLEWARE` list and set `WATCHUP_API_KEY` in `settings.py`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# settings.py
|
|
94
|
+
MIDDLEWARE = [
|
|
95
|
+
"watchup.WatchupDjangoMiddleware",
|
|
96
|
+
# ... rest of your middleware
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
WATCHUP_API_KEY = "wup_live_xxxxxxxxxxxx"
|
|
100
|
+
WATCHUP_ENVIRONMENT = "production" # optional
|
|
101
|
+
WATCHUP_RELEASE = "v1.2.3" # optional
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The middleware initialises the client once on first request and reuses it for the lifetime of the process.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## FastAPI / Starlette
|
|
109
|
+
|
|
110
|
+
Use the WSGI wrapper or instrument manually with `before` / `after` logic via Starlette middleware:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fastapi import FastAPI, Request
|
|
114
|
+
from watchup import Watchup
|
|
115
|
+
import time
|
|
116
|
+
|
|
117
|
+
watchup = Watchup(api_key="wup_live_xxxxxxxxxxxx")
|
|
118
|
+
app = FastAPI()
|
|
119
|
+
|
|
120
|
+
@app.middleware("http")
|
|
121
|
+
async def watchup_middleware(request: Request, call_next):
|
|
122
|
+
start = time.time()
|
|
123
|
+
response = await call_next(request)
|
|
124
|
+
ms = (time.time() - start) * 1000
|
|
125
|
+
# record trace manually
|
|
126
|
+
end = watchup.start_trace(f"{request.method} {request.url.path}")
|
|
127
|
+
end()
|
|
128
|
+
return response
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## WSGI middleware (framework-agnostic)
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from watchup import Watchup, WatchupWSGI
|
|
137
|
+
|
|
138
|
+
watchup = Watchup(api_key="wup_live_xxxxxxxxxxxx")
|
|
139
|
+
|
|
140
|
+
# Flask example
|
|
141
|
+
from flask import Flask
|
|
142
|
+
flask_app = Flask(__name__)
|
|
143
|
+
flask_app.wsgi_app = WatchupWSGI(flask_app.wsgi_app, watchup)
|
|
144
|
+
|
|
145
|
+
# Any WSGI app
|
|
146
|
+
application = WatchupWSGI(application, watchup)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Manual tracking
|
|
152
|
+
|
|
153
|
+
### Capture an error
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
try:
|
|
157
|
+
process_order(order_id)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
watchup.capture_error(exc, route="job.process_order", order_id=order_id)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Track a custom event
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
watchup.track("user.signed_up", {"plan": "pro", "source": "invite"})
|
|
166
|
+
watchup.track("order.placed", {"amount": 4999, "currency": "NGN"})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Time an operation
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
end = watchup.start_trace("db.query_users")
|
|
173
|
+
try:
|
|
174
|
+
rows = db.query("SELECT * FROM users")
|
|
175
|
+
end() # status defaults to "ok"
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
end(status="err", meta={"query": "SELECT * FROM users"})
|
|
178
|
+
raise
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## User identification
|
|
184
|
+
|
|
185
|
+
Attach user identity to errors and traces:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
# After authentication — in a middleware or login view
|
|
189
|
+
watchup.set_user("usr_42", email="alice@example.com", name="Alice", plan="pro")
|
|
190
|
+
|
|
191
|
+
# On logout
|
|
192
|
+
watchup.clear_user()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Once set, every `capture_error`, `start_trace`, and request trace will include the user context.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Configuration reference
|
|
200
|
+
|
|
201
|
+
| Parameter | Default | Description |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `api_key` | *(required)* | Project API key (`wup_live_…`) |
|
|
204
|
+
| `base_url` | `https://api.watchup.site` | Override for self-hosted deployments |
|
|
205
|
+
| `environment` | `WATCHUP_ENV` env var, or `"production"` | Runtime label on every payload |
|
|
206
|
+
| `release` | `None` | App version / git SHA |
|
|
207
|
+
| `flush_interval` | `5.0` | Seconds between automatic flushes |
|
|
208
|
+
| `max_batch_size` | `100` | Item count that triggers an immediate flush |
|
|
209
|
+
| `sample_rate` | `1.0` | Fraction of requests to trace (0–1) |
|
|
210
|
+
| `debug` | `False` | Log SDK warnings to stderr |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Lifecycle
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
# Force an immediate flush
|
|
218
|
+
watchup.flush()
|
|
219
|
+
|
|
220
|
+
# Stop the background timer and flush remaining items (graceful shutdown)
|
|
221
|
+
watchup.shutdown()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The batcher runs on a daemon thread and registers an `atexit` handler, so queued items are flushed automatically when the process exits normally.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Links
|
|
229
|
+
|
|
230
|
+
- **Website:** [watchup.site](https://watchup.site)
|
|
231
|
+
- **Documentation:** [watchup.site/docs](https://watchup.site/docs)
|
|
232
|
+
- **Python SDK docs:** [watchup.site/docs/sdks/python](https://watchup.site/docs/sdks/python)
|
|
233
|
+
- **Getting started:** [watchup.site/docs/getting-started](https://watchup.site/docs/getting-started)
|
|
234
|
+
- **Pricing:** [watchup.site/pricing](https://watchup.site/pricing)
|
|
235
|
+
- **Dashboard:** [app.watchup.site](https://watchup.site/login)
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT © Watchup Ltd
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
watchup/__init__.py,sha256=MWTtlFu61AtpQK-i7mq_uwANPIFsmdJjxgwx1FmOato,617
|
|
2
|
+
watchup/batcher.py,sha256=OCSmiQtdOF19lBvvlW1opmjC3apOsGPF7h_XI15AgkU,3564
|
|
3
|
+
watchup/client.py,sha256=A4vrJYfFIFmVM8rDF6HFh0Jhw5wxLnpLjOE4BuK36rk,8366
|
|
4
|
+
watchup/middleware.py,sha256=AABaqxN8w6M95vbZiqKzLLAIqSoglyvaLHdcxcw6cLo,8852
|
|
5
|
+
watchup/transport.py,sha256=tsdYxAAgqwirRWDA-I--ljPLdpSmfjsXem_cgzVoSHo,1719
|
|
6
|
+
watchup/types.py,sha256=YXvG40-WK1MQ7ji_hWf2LqtjE8WBjKM65MO8F92BsTs,3678
|
|
7
|
+
watchup-0.1.0.dist-info/METADATA,sha256=-7SJVC8hVhBGoMUFQS78H7cyT0Z7eah8SGwxSFbYGaQ,6816
|
|
8
|
+
watchup-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
watchup-0.1.0.dist-info/top_level.txt,sha256=BKuy0-hljNjMZG9ug8r0akTV2bfT6EYT24SQRirXOT4,8
|
|
10
|
+
watchup-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watchup
|