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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ watchup