changewatcher 1.0.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.
@@ -0,0 +1,948 @@
1
+ """ChangeWatch Zero-Dependency Python SDK.
2
+
3
+ Observes application health, captures exceptions and slow requests, and emits
4
+ minute-level traffic heartbeats with hard performance and safety guarantees:
5
+ - Never raises into host app (all public calls wrapped, errors counted)
6
+ - Never alters app behavior or inspects request/response bodies
7
+ - Never blocks (bounded queue, background daemon thread)
8
+ - Fork-safe (os.register_at_fork restores background thread after fork)
9
+ - Circuit breaker (trips after 5 consecutive failures, exponential backoff up to 10m)
10
+ - Kill switch (CHANGEWATCH_DISABLED=1 makes SDK a zero-overhead no-op)
11
+ - Strict HTTPS enforcement (http:// rejected unless localhost or allow_insecure=True)
12
+ - Clean shutdown (restores excepthooks, drains worker)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import atexit
17
+ import collections
18
+ import contextvars
19
+ import json
20
+ import logging
21
+ import os
22
+ import queue
23
+ import re
24
+ import sys
25
+ import threading
26
+ import time
27
+ import traceback
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+ import uuid
32
+ from decimal import ROUND_HALF_UP, Decimal
33
+ from typing import Any, Callable, Dict, List, Optional, Tuple
34
+
35
+ logger = logging.getLogger("changewatch")
36
+
37
+ # Secret scrubbing patterns (Standard library regex)
38
+ _AWS_KEY_PATTERN = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
39
+ _GITHUB_TOKEN_PATTERN = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{36,}|github_pat_[A-Za-z0-9_]{20,})\b")
40
+ _SK_TOKEN_PATTERN = re.compile(r"\b(?:sk-[A-Za-z0-9_-]{10,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abprs]-[A-Za-z0-9-]{10,})\b")
41
+ _BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9_\-.~+/]{10,}=*")
42
+ # A secret assigned to a named key, also with a prefix (DB_PASSWORD, client_secret, refresh_token)
43
+ # or quotes ("password": "..."). Same pattern as the server's app/scrub.py. A match only starts
44
+ # where a word starts and the prefix has no hyphens, so the scan stays linear.
45
+ _KV_PATTERN = re.compile(
46
+ r"(?i)(?P<prefix>(?<![A-Za-z0-9_])[A-Za-z0-9_]*?"
47
+ r"(?:password|passwd|pwd|secret(?:[_-]?key)?|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credentials?|auth)"
48
+ r"(?![A-Za-z0-9_])[\"']?\s*[:=]\s*)"
49
+ r"(?P<val>(?![\"']?\[REDACTED_)(?:\"[^\"\n]{0,500}\"|'[^'\n]{0,500}'|[^\s\"',;&)}\]]+))"
50
+ )
51
+ _TOKEN_LABELS = (
52
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED_AWS_KEY]"),
53
+ (re.compile(r"(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]{20,}"), "[REDACTED_GITHUB_TOKEN]"),
54
+ (re.compile(r"(?:sk-|[rs]k_(?:live|test)_|xox[abprs]-)[A-Za-z0-9_-]{10,}"), "[REDACTED_SECRET_KEY]"),
55
+ )
56
+
57
+
58
+ def _redact_kv(match: "re.Match") -> str:
59
+ val = match.group("val")
60
+ quote = val[0] if val[:1] in ("'", '"') and len(val) >= 2 and val[-1] == val[0] else ""
61
+ inner = val[1:-1] if quote else val
62
+ label = next((name for pattern, name in _TOKEN_LABELS if pattern.match(inner)), "[REDACTED_SECRET]")
63
+ return f"{match.group('prefix')}{quote}{label}{quote}"
64
+
65
+
66
+ # PII patterns
67
+ # The lookbehind lets a match start only where a run of local-part characters starts. Without it,
68
+ # every position inside a long run like "a-a-a-..." was tried again: quadratic, seconds per 40 KB.
69
+ _EMAIL_PATTERN = re.compile(r"(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
70
+ _IPV4_PATTERN = re.compile(r"\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b")
71
+ _IPV6_PATTERN = re.compile(r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b")
72
+ _JWT_PATTERN = re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_.-]{10,}\b")
73
+ _CARD_PATTERN = re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b")
74
+
75
+
76
+ def _scrub(text: Optional[str]) -> Optional[str]:
77
+ """Scrub sensitive keys, tokens, and PII from strings."""
78
+ if not text:
79
+ return text
80
+ scrubbed = _AWS_KEY_PATTERN.sub("[REDACTED_AWS_KEY]", text)
81
+ scrubbed = _GITHUB_TOKEN_PATTERN.sub("[REDACTED_GITHUB_TOKEN]", scrubbed)
82
+ scrubbed = _SK_TOKEN_PATTERN.sub("[REDACTED_SECRET_KEY]", scrubbed)
83
+ scrubbed = _BEARER_PATTERN.sub("Bearer [REDACTED_SECRET]", scrubbed)
84
+ scrubbed = _KV_PATTERN.sub(_redact_kv, scrubbed)
85
+
86
+ # PII Scrubbing (SCRUB_PII=true by default)
87
+ if os.getenv("SCRUB_PII", "1").lower() in ("1", "true", "yes"):
88
+ scrubbed = _JWT_PATTERN.sub("[REDACTED_JWT]", scrubbed)
89
+ scrubbed = _EMAIL_PATTERN.sub("[REDACTED_EMAIL]", scrubbed)
90
+ scrubbed = _IPV4_PATTERN.sub("[REDACTED_IP]", scrubbed)
91
+ scrubbed = _IPV6_PATTERN.sub("[REDACTED_IP]", scrubbed)
92
+ scrubbed = _CARD_PATTERN.sub("[REDACTED_CARD]", scrubbed)
93
+
94
+ return scrubbed
95
+
96
+
97
+
98
+ _MAX_EXTRA_BYTES = 10000 # the server rejects an event whose extra is over 10 KB
99
+ _MAX_EVENT_AGE_SEC = 23 * 3600 # the server rejects events older than 24 hours
100
+ # The server's limits for calls, input tokens, output tokens and cost_micros in one ai_usage
101
+ # event: one sum past them made it reject the whole batch, error events in it included
102
+ _AI_USAGE_LIMITS = (2_147_483_647, 10**12, 10**12, 10**12)
103
+
104
+
105
+ def _json_safe_extra(extra: Dict[str, Any]) -> Dict[str, Any]:
106
+ """`extra` as plain JSON (UUIDs, datetimes, Decimals become strings), within the server's limit."""
107
+ try:
108
+ text = json.dumps(extra, default=str)
109
+ except Exception:
110
+ return {"_unserializable": True}
111
+ if len(text.encode("utf-8")) > _MAX_EXTRA_BYTES:
112
+ return {"_truncated": True, "_bytes": len(text.encode("utf-8"))}
113
+ return json.loads(text)
114
+
115
+
116
+ def _scrub_value(value: Any, depth: int = 0) -> Any:
117
+ """Scrub strings at any depth of dicts, lists and tuples (dicts inside lists too)."""
118
+ if isinstance(value, str):
119
+ return _scrub(value)
120
+ if depth > 20:
121
+ return "[TOO_DEEP]"
122
+ if isinstance(value, dict):
123
+ return {k: _scrub_value(v, depth + 1) for k, v in value.items()}
124
+ if isinstance(value, (list, tuple)):
125
+ return [_scrub_value(v, depth + 1) for v in value]
126
+ return value
127
+
128
+
129
+ def _scrub_dict(data: Dict[str, Any]) -> Dict[str, Any]:
130
+ """Recursively scrub values in dictionaries."""
131
+ return _scrub_value(data)
132
+
133
+
134
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
135
+ """Never follow redirects: urllib would send the API key to the new host (even over plain
136
+ HTTP) and turn the POST into a GET without the events."""
137
+
138
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
139
+ return None # urllib then raises HTTPError with the 3xx status
140
+
141
+
142
+ _OPENER = urllib.request.build_opener(_NoRedirect)
143
+
144
+
145
+ def _reaches_root(name: Any) -> bool:
146
+ """Whether a record of this logger reaches the root logger, i.e. the app's own log output
147
+ (walked up as Logger.callHandlers does): a disabled logger or propagate=False stops it."""
148
+ if not isinstance(name, str) or name.startswith("changewatch"):
149
+ return False # our own warnings: never a loop
150
+ node = logging.root.manager.loggerDict.get(name)
151
+ if node is None:
152
+ return name == logging.root.name and not logging.root.disabled
153
+ if not isinstance(node, logging.Logger) or node.disabled:
154
+ return False
155
+ while node.parent is not None:
156
+ if not node.propagate:
157
+ return False
158
+ node = node.parent
159
+ return True
160
+
161
+
162
+ class _LogBuffer(logging.Handler):
163
+ """Keeps the process's last log lines in memory, to attach to the next error (capture_logs).
164
+
165
+ It is not added to any logger: a log record factory, chained to the one before it, hands it
166
+ each new record that reaches the root logger. A handler on the root logger made a later
167
+ logging.basicConfig() do nothing, hid Python's own output of errors while logging is not
168
+ configured, and logging.config.dictConfig() removed it without a sign.
169
+
170
+ Nothing is sent on its own. Lines are cut when recorded and scrubbed only when an error takes
171
+ them, so logging stays cheap. With capture_logged_errors, `logger.exception(...)` (an error
172
+ the app caught and only logged) is also reported as an error.
173
+ """
174
+
175
+ def __init__(self, client: "Client", level: int, capacity: int = 50, capture_logged_errors: bool = False):
176
+ super().__init__(level)
177
+ self._client = client
178
+ self._lines: "collections.deque" = collections.deque(maxlen=capacity)
179
+ self._capture_logged_errors = capture_logged_errors
180
+ # The lowest level worth a look: kept lines (capacity 0 keeps none) and logged errors
181
+ never = sys.maxsize
182
+ self._threshold = min(self.level if capacity else never, logging.ERROR if capture_logged_errors else never)
183
+ self._previous_factory = logging.getLogRecordFactory()
184
+ self._factory = self._make_record # one bound method, to recognise it as ours later
185
+ self._active = True
186
+ logging.setLogRecordFactory(self._factory)
187
+
188
+ def _make_record(self, *args: Any, **kwargs: Any) -> logging.LogRecord:
189
+ # Logging only creates a record for a level its logger is enabled for
190
+ record = self._previous_factory(*args, **kwargs)
191
+ try:
192
+ if self._active and (record.levelno or 0) >= self._threshold and _reaches_root(record.name):
193
+ self.handle(record)
194
+ except Exception:
195
+ self._client._internal_errors += 1
196
+ return record
197
+
198
+ def detach(self) -> None:
199
+ """Stop looking at log records: put the previous record factory back while ours is still
200
+ the current one. If another library has wrapped ours since, ours only passes records on."""
201
+ self._active = False
202
+ if logging.getLogRecordFactory() is self._factory:
203
+ logging.setLogRecordFactory(self._previous_factory)
204
+
205
+ def emit(self, record: logging.LogRecord) -> None:
206
+ try:
207
+ if self._lines.maxlen and record.levelno >= self.level:
208
+ message = record.getMessage()
209
+ if len(message) > 1000:
210
+ # Cut after the last whole word: a value cut in two (an email, a token) no
211
+ # longer matches the scrubber
212
+ message = (message[:1000].rsplit(None, 1) or [""])[0]
213
+ self._lines.append((record.created, record.levelname, record.name, message))
214
+ exc = record.exc_info[1] if record.exc_info else None
215
+ if self._capture_logged_errors and exc is not None and record.levelno >= logging.ERROR:
216
+ self._client._capture(exc, {"logger": record.name[:100]}, None, None, "log")
217
+ except Exception:
218
+ self._client._internal_errors += 1
219
+
220
+ def snapshot(self) -> List[Dict[str, Any]]:
221
+ """The buffered lines, oldest first, scrubbed and cut to the server's limits."""
222
+ with self.lock:
223
+ lines = list(self._lines)
224
+ # Scrubbed whole, then cut: cutting first left a value that crossed the cut unredacted
225
+ return [
226
+ {"ts": ts, "level": level[:10], "logger": (_scrub(name[:100]) or "")[:100],
227
+ "message": (_scrub(message) or "")[:500]}
228
+ for ts, level, name, message in lines
229
+ ]
230
+
231
+
232
+ class _Request:
233
+ """The request ASGIMiddleware is handling."""
234
+
235
+ __slots__ = ("path", "method", "done")
236
+
237
+ def __init__(self, path: str, method: str):
238
+ self.path, self.method, self.done = path, method, False
239
+
240
+
241
+ # Set by ASGIMiddleware at the start of each request, so captures that do not pass a path (a
242
+ # logged exception, the app's own capture_exception call) carry the request's path and method.
243
+ # Code started for the request sees it too (asyncio tasks and worker threads get a copy of the
244
+ # context). It is left in place when the request ends: the server's own report of what the
245
+ # request raised (uvicorn logs it) comes right after, in the same context.
246
+ _current_request: "contextvars.ContextVar[Optional[_Request]]" = contextvars.ContextVar("changewatch_request", default=None)
247
+
248
+
249
+ def _tb_in_chain(mark: Any, tb: Any) -> bool:
250
+ """Whether traceback entry `mark` is part of traceback `tb`. An exception that propagates only
251
+ gets entries added in front, so this holds while it is still unwinding from `mark`."""
252
+ if mark is None:
253
+ return tb is None
254
+ while tb is not None:
255
+ if tb is mark:
256
+ return True
257
+ tb = tb.tb_next
258
+ return False
259
+
260
+
261
+ def _already_reported(exc: BaseException, source: str, request: Optional[_Request]) -> bool:
262
+ """Whether this capture repeats one already sent, such as an exception logged (with
263
+ capture_logged_errors) and then raised on to the middleware or an exception hook.
264
+
265
+ In a request: once per request. A failed shared future or a cached error raises the same
266
+ exception object in every request, and every failing request must still send an event.
267
+ Outside a request: when another kind of capture ("log", "app" for capture_exception, "hook"
268
+ for an exception hook) sent it from this thread and it is still unwinding from there. Two
269
+ captures of the same kind both send: that is what one object raised again in a loop looks like.
270
+ """
271
+ try:
272
+ seen = getattr(exc, "_changewatch_reported", None)
273
+ if not seen:
274
+ return False
275
+ where, tb, by = seen
276
+ if request is not None:
277
+ return where is request
278
+ if where != threading.get_ident() or (by == source and source != "hook"):
279
+ return False
280
+ return _tb_in_chain(tb, exc.__traceback__)
281
+ except Exception:
282
+ return False
283
+
284
+
285
+ def _mark_reported(exc: BaseException, source: str, request: Optional[_Request]) -> None:
286
+ try:
287
+ if request is not None:
288
+ exc._changewatch_reported = (request, None, source) # type: ignore[attr-defined]
289
+ else:
290
+ exc._changewatch_reported = (threading.get_ident(), exc.__traceback__, source) # type: ignore[attr-defined]
291
+ except Exception:
292
+ pass # an exception that takes no attributes is sent again if another hook sees it
293
+
294
+
295
+ class Client:
296
+ """ChangeWatch SDK Client managing event batching, circuit breaking, and delivery."""
297
+
298
+ def __init__(
299
+ self,
300
+ api_key: str,
301
+ release: Optional[str] = None,
302
+ environment: str = "prod",
303
+ endpoint: Optional[str] = None,
304
+ slow_ms: float = 1500.0,
305
+ allow_insecure: bool = False,
306
+ capture_logs: bool = False,
307
+ log_level: int = logging.WARNING,
308
+ capture_logged_errors: bool = False,
309
+ ):
310
+ self.disabled = os.getenv("CHANGEWATCH_DISABLED", "0").lower() in ("1", "true", "yes")
311
+ self.api_key = api_key
312
+ # Cut once to the server's limits: an over-long value in any event (heartbeats included)
313
+ # made the server reject the whole batch
314
+ self.release = (release or os.getenv("GIT_SHA", ""))[:64]
315
+ self.environment = (environment or "prod")[:50]
316
+ self.slow_ms = float(slow_ms)
317
+ self.allow_insecure = allow_insecure or (os.getenv("ALLOW_INSECURE_HTTP", "0").lower() in ("1", "true", "yes"))
318
+
319
+ raw_endpoint = (endpoint or os.getenv("CHANGEWATCH_ENDPOINT", "http://localhost:8000")).rstrip("/")
320
+ self.endpoint = self._validate_and_format_endpoint(raw_endpoint)
321
+
322
+ # Internal failure counter (swallowed errors)
323
+ self._internal_errors = 0
324
+
325
+ # Circuit breaker state
326
+ self._consecutive_failures = 0
327
+ self._circuit_open_until = 0.0
328
+ self._backoff_seconds = 60.0
329
+ self._max_backoff_seconds = 600.0 # 10 minutes
330
+ self._dropped_events = 0 # events lost because the bounded queue was full
331
+
332
+ # Threading and queue state
333
+ self._queue: queue.Queue = queue.Queue(maxsize=1000)
334
+ self._running = not self.disabled
335
+ self._lock = threading.Lock()
336
+
337
+ # Heartbeat counters
338
+ self._heartbeat_requests = 0
339
+ self._heartbeat_errors = 0
340
+ self._last_heartbeat_time = time.time()
341
+
342
+ # The app's own AI calls, summed per (model, feature, cost known) and sent once a minute
343
+ self._ai_usage: Dict[Tuple[str, str, bool], List[int]] = {}
344
+ self._last_ai_flush = time.time()
345
+
346
+ # The app's recent log lines, attached to errors (opt-in: capture_logs), and exceptions
347
+ # it only logs (opt-in: capture_logged_errors, which works on its own too)
348
+ self._log_handler: Optional[_LogBuffer] = None
349
+ if (capture_logs or capture_logged_errors) and not self.disabled:
350
+ self._log_handler = _LogBuffer(self, level=log_level, capacity=50 if capture_logs else 0,
351
+ capture_logged_errors=capture_logged_errors)
352
+
353
+ # Background worker thread (only started if not disabled)
354
+ self._worker_thread: Optional[threading.Thread] = None
355
+ if not self.disabled:
356
+ self._start_worker_thread()
357
+
358
+ # Register at-fork handler for preloaded server environments (Gunicorn, uWSGI)
359
+ if hasattr(os, "register_at_fork"):
360
+ try:
361
+ os.register_at_fork(after_in_child=self._reinit_after_fork)
362
+ except Exception:
363
+ pass
364
+
365
+ atexit.register(self._flush_at_exit)
366
+
367
+ def _validate_and_format_endpoint(self, endpoint: str) -> str:
368
+ """Validate HTTPS requirement unless localhost or explicitly allowed."""
369
+ parsed = urllib.parse.urlparse(endpoint)
370
+ hostname = (parsed.hostname or "").lower()
371
+ scheme = (parsed.scheme or "").lower()
372
+
373
+ is_localhost = hostname in ("localhost", "127.0.0.1", "::1", "testserver")
374
+
375
+ if scheme == "http" and not is_localhost and not self.allow_insecure:
376
+ raise ValueError(
377
+ f"ChangeWatch security violation: Insecure endpoint '{endpoint}' is prohibited. "
378
+ "ChangeWatch requires HTTPS for production endpoints. Use https:// or set allow_insecure=True."
379
+ )
380
+
381
+ if scheme == "http" and not is_localhost and self.allow_insecure:
382
+ logger.warning("ChangeWatch warning: Insecure HTTP connection explicitly allowed for %s", endpoint)
383
+
384
+ return endpoint
385
+
386
+ def _start_worker_thread(self):
387
+ """Start or restart daemon worker thread."""
388
+ self._running = True
389
+ self._worker_thread = threading.Thread(
390
+ target=self._worker_loop, daemon=True, name="changewatch-worker"
391
+ )
392
+ self._worker_thread.start()
393
+
394
+ def _reinit_after_fork(self):
395
+ """Fork-safety handler: reinitializes lock, queue, and background thread in child process."""
396
+ if self.disabled:
397
+ return
398
+ try:
399
+ self._lock = threading.Lock()
400
+ # Drain or re-create child queue
401
+ self._queue = queue.Queue(maxsize=1000)
402
+ self._heartbeat_requests = 0
403
+ self._heartbeat_errors = 0
404
+ self._ai_usage = {}
405
+ self._consecutive_failures = 0
406
+ self._circuit_open_until = 0.0
407
+ self._start_worker_thread()
408
+ except Exception:
409
+ self._internal_errors += 1
410
+
411
+ def record_request(self, has_error: bool = False):
412
+ """Record a request for the 60-second traffic heartbeat (non-blocking, never raises)."""
413
+ if self.disabled:
414
+ return
415
+ try:
416
+ with self._lock:
417
+ self._heartbeat_requests += 1
418
+ if has_error:
419
+ self._heartbeat_errors += 1
420
+
421
+ now = time.time()
422
+ if now - self._last_heartbeat_time >= 60.0:
423
+ self._emit_heartbeat_locked(now)
424
+ except Exception:
425
+ self._internal_errors += 1
426
+
427
+ def record_ai_usage(
428
+ self,
429
+ model: str,
430
+ input_tokens: int = 0,
431
+ output_tokens: int = 0,
432
+ cost_usd: Any = None,
433
+ feature: Optional[str] = None,
434
+ calls: int = 1,
435
+ ) -> None:
436
+ """Count one (or `calls`) AI call of the app for cost tracking. Never raises, never blocks.
437
+
438
+ The server prices it from its model price list unless `cost_usd` (the exact cost, as a
439
+ number or string) is given. `feature` names what the call is for ("search", "summary").
440
+ """
441
+ if self.disabled:
442
+ return
443
+ try:
444
+ cost_micros = None
445
+ if cost_usd is not None:
446
+ cost_micros = int((Decimal(str(cost_usd)) * 1_000_000).to_integral_value(rounding=ROUND_HALF_UP))
447
+ key = (str(model or "unknown")[:100], str(feature or "")[:100], cost_micros is not None)
448
+ added = (max(0, int(calls)), max(0, int(input_tokens or 0)), max(0, int(output_tokens or 0)),
449
+ max(0, cost_micros or 0))
450
+ with self._lock:
451
+ totals = self._ai_usage.setdefault(key, [0, 0, 0, 0])
452
+ for i, limit in enumerate(_AI_USAGE_LIMITS):
453
+ totals[i] += added[i]
454
+ if totals[i] > limit:
455
+ totals[i] = limit
456
+ self._internal_errors += 1 # counted: what went past the limit is lost
457
+ except Exception:
458
+ self._internal_errors += 1
459
+
460
+ def _flush_ai_usage_locked(self, now: float) -> None:
461
+ """Queue the summed AI usage (called under the lock); what does not fit stays for later."""
462
+ for key in list(self._ai_usage):
463
+ model, feature, has_cost = key
464
+ calls, tokens_in, tokens_out, cost = self._ai_usage[key]
465
+ event: Dict[str, Any] = {
466
+ "event_id": uuid.uuid4().hex, "type": "ai_usage", "model": model, "feature": feature or None,
467
+ "calls": calls, "input_tokens": tokens_in, "output_tokens": tokens_out,
468
+ "release": self.release or None, "environment": self.environment, "occurred_at": now,
469
+ }
470
+ if has_cost:
471
+ event["cost_micros"] = cost
472
+ try:
473
+ self._queue.put_nowait(event)
474
+ except queue.Full:
475
+ return
476
+ del self._ai_usage[key]
477
+ self._last_ai_flush = now
478
+
479
+ def _emit_heartbeat_locked(self, now: float):
480
+ """Enqueue heartbeat event if requests > 0 (called under lock)."""
481
+ if self._heartbeat_requests > 0:
482
+ event = {
483
+ "event_id": uuid.uuid4().hex,
484
+ "type": "heartbeat",
485
+ "requests": self._heartbeat_requests,
486
+ "errors": self._heartbeat_errors,
487
+ "release": self.release or None,
488
+ "environment": self.environment,
489
+ "occurred_at": now,
490
+ }
491
+ try:
492
+ self._queue.put_nowait(event)
493
+ except queue.Full:
494
+ return # keep the counts: the next heartbeat carries them
495
+ self._heartbeat_requests = 0
496
+ self._heartbeat_errors = 0
497
+ self._last_heartbeat_time = now
498
+
499
+ def capture_exception(
500
+ self,
501
+ exc: BaseException,
502
+ extra: Optional[Dict[str, Any]] = None,
503
+ request_path: Optional[str] = None,
504
+ method: Optional[str] = None,
505
+ ):
506
+ """Capture an unhandled or manual exception (non-blocking, never raises)."""
507
+ self._capture(exc, extra, request_path, method, "app")
508
+
509
+ def _capture(
510
+ self,
511
+ exc: BaseException,
512
+ extra: Optional[Dict[str, Any]],
513
+ request_path: Optional[str],
514
+ method: Optional[str],
515
+ source: str,
516
+ ):
517
+ """capture_exception, told which kind of capture this is: "app", "log" or "hook"."""
518
+ if self.disabled:
519
+ return
520
+ try:
521
+ request = _current_request.get()
522
+ if _already_reported(exc, source, request):
523
+ return # e.g. logged with capture_logged_errors, then raised on to a hook
524
+ if request is not None and not request.done:
525
+ request_path = request_path or request.path
526
+ method = method or request.method
527
+ exc_type = type(exc).__name__[:200]
528
+ # Cut to the server's limits (with room for redaction markers) before scrubbing, so
529
+ # scrubbing time stays bounded however large the exception text is
530
+ message = _scrub(str(exc)[:4000])
531
+ tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
532
+ stack_trace = _scrub(tb_str[-40000:])
533
+
534
+ clean_extra = _json_safe_extra(_scrub_dict(extra) if extra else {})
535
+ if method:
536
+ clean_extra["method"] = str(method)[:20]
537
+
538
+ event = {
539
+ "event_id": uuid.uuid4().hex,
540
+ "type": "error",
541
+ "exc_type": exc_type,
542
+ "message": (message or "")[:2000],
543
+ "stack_trace": (stack_trace or "")[-20000:],
544
+ "request_path": (_scrub(request_path[:500]) or "")[:500] if request_path else None,
545
+ "release": self.release or None,
546
+ "environment": self.environment,
547
+ "occurred_at": time.time(),
548
+ "extra": clean_extra,
549
+ }
550
+ log_handler = self._log_handler
551
+ if log_handler is not None:
552
+ logs = log_handler.snapshot()
553
+ if logs:
554
+ event["logs"] = logs
555
+ self._queue.put_nowait(event)
556
+ _mark_reported(exc, source, request) # only once queued: a dropped one can come again
557
+ except queue.Full:
558
+ self._dropped_events += 1
559
+ except Exception:
560
+ self._internal_errors += 1
561
+
562
+ def capture_slow_request(self, request_path: str, duration_ms: float, method: Optional[str] = None):
563
+ """Capture a slow request exceeding slow_ms threshold (non-blocking, never raises)."""
564
+ if self.disabled:
565
+ return
566
+ try:
567
+ extra = {"method": method} if method else {}
568
+ event = {
569
+ "event_id": uuid.uuid4().hex,
570
+ "type": "slow_request",
571
+ "request_path": (_scrub(request_path[:500]) or "")[:500], # markers can be longer than what they replace
572
+ "duration_ms": round(duration_ms, 2),
573
+ "release": self.release or None,
574
+ "environment": self.environment,
575
+ "occurred_at": time.time(),
576
+ "extra": extra,
577
+ }
578
+ self._queue.put_nowait(event)
579
+ except queue.Full:
580
+ self._dropped_events += 1
581
+ except Exception:
582
+ self._internal_errors += 1
583
+
584
+ def _worker_loop(self):
585
+ """Background thread worker pulling events, batching up to 20 or 2 seconds."""
586
+ batch: List[Dict[str, Any]] = []
587
+ last_flush = time.monotonic()
588
+
589
+ while self._running:
590
+ try:
591
+ # While paused (circuit open / server asked to retry later) leave events queued
592
+ if self._ai_usage and time.time() - self._last_ai_flush >= 60.0:
593
+ with self._lock:
594
+ self._flush_ai_usage_locked(time.time())
595
+
596
+ pause = self._circuit_open_until - time.monotonic()
597
+ if pause > 0:
598
+ time.sleep(min(0.5, pause))
599
+ continue
600
+
601
+ elapsed = time.monotonic() - last_flush
602
+ timeout = max(0.05, 2.0 - elapsed)
603
+
604
+ try:
605
+ event = self._queue.get(timeout=timeout)
606
+ batch.append(event)
607
+ except queue.Empty:
608
+ pass
609
+
610
+ now = time.monotonic()
611
+ if len(batch) >= 20 or (batch and (now - last_flush) >= 2.0):
612
+ try:
613
+ self._send_batch(batch)
614
+ finally:
615
+ # Never retry the same list here: a batch that cannot be sent must not
616
+ # block every later event (and grow without bound)
617
+ batch = []
618
+ last_flush = now
619
+
620
+ except Exception:
621
+ self._internal_errors += 1
622
+
623
+ if batch:
624
+ # Stopping (at exit): hand the batch back so the final flush sends it
625
+ self._requeue(batch)
626
+
627
+ def _send_batch(self, batch: List[Dict[str, Any]], final: bool = False) -> bool:
628
+ """Send a batch of events via HTTP POST with 3s timeout. Never raises.
629
+
630
+ Enforces circuit breaker:
631
+ - If circuit is open, drops send attempt without network cost.
632
+ - After 5 consecutive failures, circuit opens for 60s with exponential backoff up to 10m.
633
+ - HTTP 429/503 pause sending for Retry-After seconds; the batch is re-queued.
634
+ - Network failures re-queue the batch (bounded queue, overflow dropped and counted).
635
+ `final` (at exit) tries once even while paused and keeps nothing for later.
636
+ Returns True when the server accepted the batch.
637
+ """
638
+ if not batch or self.disabled:
639
+ return False
640
+
641
+ # Check circuit breaker
642
+ now = time.monotonic()
643
+ if now < self._circuit_open_until and not final:
644
+ return False # Circuit open, drop batch
645
+
646
+ # The server rejects a whole request if one event is older than 24 hours (after a long
647
+ # outage, re-queued events can be): leave those out and count them
648
+ cutoff = time.time() - _MAX_EVENT_AGE_SEC
649
+ fresh = [e for e in batch if e.get("occurred_at", 0) >= cutoff]
650
+ self._dropped_events += len(batch) - len(fresh)
651
+ if not fresh:
652
+ return False
653
+ batch = fresh
654
+
655
+ url = f"{self.endpoint}/v1/events"
656
+ payload = json.dumps({"events": batch}, default=str).encode("utf-8")
657
+ headers = {
658
+ "Content-Type": "application/json",
659
+ "X-API-Key": self.api_key,
660
+ "User-Agent": "changewatch-python/1.0.0",
661
+ }
662
+ req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
663
+
664
+ try:
665
+ with _OPENER.open(req, timeout=3.0) as resp:
666
+ _ = resp.read()
667
+ # Success: reset circuit breaker
668
+ self._consecutive_failures = 0
669
+ self._circuit_open_until = 0.0
670
+ self._backoff_seconds = 60.0
671
+ return True
672
+ except urllib.error.HTTPError as exc:
673
+ self._internal_errors += 1
674
+ if 300 <= exc.code < 400:
675
+ logger.warning("ChangeWatch endpoint answered with a redirect (%d); set endpoint to the final URL", exc.code)
676
+ self._record_failure()
677
+ elif exc.code in (429, 503) and not final:
678
+ # The server asked us to retry later (e.g. its queue is down): keep the
679
+ # events and pause sending for Retry-After seconds
680
+ self._circuit_open_until = time.monotonic() + self._retry_after_seconds(exc.headers)
681
+ self._requeue(batch)
682
+ else:
683
+ # Other HTTP errors mean this batch itself was rejected; do not retry it
684
+ self._record_failure()
685
+ except Exception:
686
+ # Network failure: the events are still good, keep them for the next attempt
687
+ self._internal_errors += 1
688
+ if not final:
689
+ self._requeue(batch)
690
+ self._record_failure()
691
+ return False
692
+
693
+ def _record_failure(self):
694
+ self._consecutive_failures += 1
695
+ if self._consecutive_failures >= 5:
696
+ # Trip circuit breaker
697
+ self._circuit_open_until = time.monotonic() + self._backoff_seconds
698
+ self._backoff_seconds = min(self._max_backoff_seconds, self._backoff_seconds * 2.0)
699
+
700
+ def _retry_after_seconds(self, headers: Any) -> float:
701
+ """Seconds from a Retry-After header (1 s to the 10 min backoff cap; 30 s if absent)."""
702
+ try:
703
+ value = float(headers.get("Retry-After")) if headers is not None else 30.0
704
+ except (TypeError, ValueError):
705
+ value = 30.0
706
+ return max(1.0, min(self._max_backoff_seconds, value))
707
+
708
+ def _requeue(self, batch: List[Dict[str, Any]]):
709
+ """Put unsent events back; the queue stays bounded, overflow is counted and dropped."""
710
+ for event in batch:
711
+ try:
712
+ self._queue.put_nowait(event)
713
+ except queue.Full:
714
+ self._dropped_events += 1
715
+
716
+ def flush(self):
717
+ """Flush pending events and final heartbeats on exit (never raises)."""
718
+ if self.disabled:
719
+ return
720
+ try:
721
+ with self._lock:
722
+ self._emit_heartbeat_locked(time.time())
723
+
724
+ events_to_send: List[Dict[str, Any]] = []
725
+ while not self._queue.empty():
726
+ try:
727
+ events_to_send.append(self._queue.get_nowait())
728
+ except queue.Empty:
729
+ break
730
+
731
+ if events_to_send:
732
+ for i in range(0, len(events_to_send), 20):
733
+ self._send_batch(events_to_send[i : i + 20])
734
+ except Exception:
735
+ self._internal_errors += 1
736
+
737
+ def _flush_at_exit(self):
738
+ """At interpreter exit: stop the worker (it hands back the batch it holds), then send
739
+ everything once. Gives up at the first failure so a dead server cannot hold up exit."""
740
+ if self.disabled:
741
+ return
742
+ try:
743
+ with self._lock:
744
+ self._emit_heartbeat_locked(time.time())
745
+ self._flush_ai_usage_locked(time.time())
746
+ self._running = False
747
+ worker = self._worker_thread
748
+ if worker is not None and worker.is_alive() and worker is not threading.current_thread():
749
+ worker.join(timeout=2.5)
750
+ pending: List[Dict[str, Any]] = []
751
+ while True:
752
+ try:
753
+ pending.append(self._queue.get_nowait())
754
+ except queue.Empty:
755
+ break
756
+ for i in range(0, len(pending), 20):
757
+ if not self._send_batch(pending[i : i + 20], final=True):
758
+ self._dropped_events += len(pending) - i
759
+ break
760
+ except Exception:
761
+ self._internal_errors += 1
762
+
763
+ def stop(self):
764
+ """Stop worker thread and drain remaining queue."""
765
+ self._stop_log_capture()
766
+ self._running = False
767
+ if self._worker_thread and self._worker_thread.is_alive():
768
+ self._worker_thread.join(timeout=1.0)
769
+
770
+ def _stop_log_capture(self):
771
+ log_handler, self._log_handler = self._log_handler, None
772
+ if log_handler is not None:
773
+ log_handler.detach()
774
+
775
+
776
+ # Global state
777
+ _client: Optional[Client] = None
778
+ _orig_sys_excepthook = None
779
+ _orig_threading_excepthook = None
780
+
781
+
782
+ def init(
783
+ api_key: str,
784
+ release: Optional[str] = None,
785
+ environment: str = "prod",
786
+ endpoint: Optional[str] = None,
787
+ slow_ms: float = 1500.0,
788
+ allow_insecure: bool = False,
789
+ capture_logs: bool = False,
790
+ log_level: int = logging.WARNING,
791
+ capture_logged_errors: bool = False,
792
+ ) -> Client:
793
+ """Initialize ChangeWatch SDK, install exception hooks, and return Client.
794
+
795
+ capture_logs: attach the app's last 50 log lines (at log_level or above) to each error.
796
+ capture_logged_errors: also report exceptions the app only logs (logger.exception(...)).
797
+ """
798
+ global _client, _orig_sys_excepthook, _orig_threading_excepthook
799
+
800
+ if _client is not None:
801
+ # Called again (an app factory, a test per app): the first client's log capture would
802
+ # otherwise stay on for good, and report through that client even after shutdown()
803
+ _client._stop_log_capture()
804
+
805
+ client = Client(
806
+ api_key=api_key,
807
+ release=release,
808
+ environment=environment,
809
+ endpoint=endpoint,
810
+ slow_ms=slow_ms,
811
+ allow_insecure=allow_insecure,
812
+ capture_logs=capture_logs,
813
+ log_level=log_level,
814
+ capture_logged_errors=capture_logged_errors,
815
+ )
816
+ _client = client
817
+
818
+ if client.disabled:
819
+ return client
820
+
821
+ # Install sys.excepthook preserving previous
822
+ if _orig_sys_excepthook is None:
823
+ _orig_sys_excepthook = sys.excepthook
824
+
825
+ def _changewatch_sys_excepthook(exc_type, exc_value, exc_traceback):
826
+ if _client and not _client.disabled and exc_value:
827
+ _client._capture(exc_value, None, None, None, "hook")
828
+ if _orig_sys_excepthook:
829
+ _orig_sys_excepthook(exc_type, exc_value, exc_traceback)
830
+
831
+ sys.excepthook = _changewatch_sys_excepthook
832
+
833
+ # Install threading.excepthook preserving previous (Python 3.8+)
834
+ if hasattr(threading, "excepthook"):
835
+ if _orig_threading_excepthook is None:
836
+ _orig_threading_excepthook = threading.excepthook
837
+
838
+ def _changewatch_threading_excepthook(args):
839
+ if _client and not _client.disabled and args.exc_value:
840
+ _client._capture(args.exc_value, None, None, None, "hook")
841
+ if _orig_threading_excepthook:
842
+ _orig_threading_excepthook(args)
843
+
844
+ threading.excepthook = _changewatch_threading_excepthook
845
+
846
+ return client
847
+
848
+
849
+ def shutdown():
850
+ """Cleanly shutdown ChangeWatch, uninstall global hooks, and drain worker."""
851
+ global _client, _orig_sys_excepthook, _orig_threading_excepthook
852
+
853
+ if _orig_sys_excepthook is not None:
854
+ sys.excepthook = _orig_sys_excepthook
855
+ _orig_sys_excepthook = None
856
+
857
+ if _orig_threading_excepthook is not None and hasattr(threading, "excepthook"):
858
+ threading.excepthook = _orig_threading_excepthook
859
+ _orig_threading_excepthook = None
860
+
861
+ if _client is not None:
862
+ _client._flush_at_exit() # stops the worker, which hands back its batch, then sends all
863
+ _client.stop()
864
+ _client = None
865
+
866
+
867
+ def capture_exception(exc: BaseException, extra: Optional[Dict[str, Any]] = None):
868
+ """Global helper to capture an exception if ChangeWatch is initialized."""
869
+ if _client is not None and not _client.disabled:
870
+ _client.capture_exception(exc, extra=extra)
871
+
872
+
873
+ def record_ai_usage(model: str, input_tokens: int = 0, output_tokens: int = 0, cost_usd: Any = None,
874
+ feature: Optional[str] = None, calls: int = 1) -> None:
875
+ """Count one AI call of the app for cost tracking (see Client.record_ai_usage)."""
876
+ if _client is not None and not _client.disabled:
877
+ _client.record_ai_usage(model, input_tokens, output_tokens, cost_usd=cost_usd, feature=feature, calls=calls)
878
+
879
+
880
+ def record_ai_usage_from_response(response: Any, model: Optional[str] = None, feature: Optional[str] = None) -> None:
881
+ """Count an AI call from the response object of the Anthropic or OpenAI SDK (or a dict like
882
+ it): reads `model` and the token counts from `usage`. Never raises.
883
+
884
+ Anthropic prompt-cache writes count as input tokens; cache reads (a small fraction of the
885
+ price) are not counted. Pass the exact cost to record_ai_usage(cost_usd=...) when you have it.
886
+ """
887
+ try:
888
+ def field(obj: Any, *names: str) -> Any:
889
+ for name in names:
890
+ value = obj.get(name) if isinstance(obj, dict) else getattr(obj, name, None)
891
+ if value is not None:
892
+ return value
893
+ return None
894
+
895
+ usage = field(response, "usage") or {}
896
+ tokens_in = int(field(usage, "input_tokens", "prompt_tokens") or 0) + int(field(usage, "cache_creation_input_tokens") or 0)
897
+ tokens_out = int(field(usage, "output_tokens", "completion_tokens") or 0)
898
+ record_ai_usage(model or field(response, "model") or "unknown", tokens_in, tokens_out, feature=feature)
899
+ except Exception:
900
+ if _client is not None:
901
+ _client._internal_errors += 1
902
+
903
+
904
+ class ASGIMiddleware:
905
+ """ASGI Middleware for FastAPI/Starlette frameworks.
906
+
907
+ Guarantees:
908
+ - Only observes duration and exceptions.
909
+ - Never reads or modifies request bodies or response bodies.
910
+ - Always re-raises original exceptions unchanged into host application.
911
+ - Completely no-op if client is uninitialized or disabled.
912
+ """
913
+
914
+ def __init__(self, app: Any, client: Optional[Client] = None):
915
+ self.app = app
916
+ self.client = client
917
+
918
+ async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable):
919
+ if scope.get("type") != "http":
920
+ await self.app(scope, receive, send)
921
+ return
922
+
923
+ active_client = self.client or _client
924
+ if active_client is None or active_client.disabled:
925
+ await self.app(scope, receive, send)
926
+ return
927
+
928
+ start_time = time.monotonic()
929
+ path = scope.get("path", "")
930
+ method = scope.get("method", "GET")
931
+ has_error = False
932
+ request = _Request(path, method)
933
+ _current_request.set(request) # not reset at the end: see _current_request
934
+
935
+ try:
936
+ await self.app(scope, receive, send)
937
+ except BaseException as exc:
938
+ has_error = True
939
+ active_client._capture(exc, None, path, method, "hook")
940
+ # Must always re-raise original exception unchanged
941
+ raise
942
+ finally:
943
+ request.done = True
944
+ duration_ms = (time.monotonic() - start_time) * 1000.0
945
+ active_client.record_request(has_error=has_error)
946
+
947
+ if not has_error and duration_ms >= active_client.slow_ms:
948
+ active_client.capture_slow_request(request_path=path, duration_ms=duration_ms, method=method)
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: changewatcher
3
+ Version: 1.0.0
4
+ Summary: ChangeWatch Python SDK for error and performance monitoring
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/aarizbobbydeol/changewatch
7
+ Project-URL: Source, https://github.com/aarizbobbydeol/changewatch/tree/main/sdk
8
+ Project-URL: Issues, https://github.com/aarizbobbydeol/changewatch/issues
9
+ Project-URL: Changelog, https://github.com/aarizbobbydeol/changewatch/blob/main/CHANGELOG.md
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # ChangeWatch Python SDK
16
+
17
+ Reports errors, slow requests and a request count per minute to a self-hosted
18
+ [ChangeWatch](https://github.com/aarizbobbydeol/changewatch) server, so it can tell you within
19
+ minutes whether a deploy broke something. No dependencies, Python 3.9 or newer.
20
+
21
+ ```bash
22
+ pip install changewatcher
23
+ ```
24
+
25
+ Imported as `changewatch`. Straight from GitHub instead: `pip install "git+https://github.com/aarizbobbydeol/changewatch.git#subdirectory=sdk"`.
26
+
27
+ ```python
28
+ import os
29
+ import changewatch
30
+ from changewatch import ASGIMiddleware
31
+ from fastapi import FastAPI
32
+
33
+ cw = changewatch.init(
34
+ api_key=os.environ["CHANGEWATCH_API_KEY"], # cw_ingest_...
35
+ release=os.environ.get("GIT_SHA"), # ties events to the deploy
36
+ endpoint="https://changewatch.example.com",
37
+ )
38
+
39
+ app = FastAPI()
40
+ app.add_middleware(ASGIMiddleware, client=cw) # errors, latency, heartbeats
41
+ ```
42
+
43
+ Optional: `capture_logs=True` attaches the app's last 50 log lines (WARNING and above, or
44
+ `log_level=`) to each error; `capture_logged_errors=True` (on its own or with `capture_logs`) also
45
+ reports exceptions that are caught and only logged with `logger.exception(...)`. An exception that
46
+ is logged and then raised on is reported once, with the request's path and method. Only lines that
47
+ reach the root logger count (as in your own log output). The SDK adds no logging handler, so
48
+ `logging.basicConfig()` and `dictConfig()` work as usual, before or after `init()`.
49
+
50
+ The SDK never raises into your app and never blocks a request: events go into a bounded queue
51
+ that a background thread sends in batches. Secrets and personal data (emails, IPs, tokens) are
52
+ removed before anything leaves the process. `CHANGEWATCH_DISABLED=1` turns it off.
@@ -0,0 +1,6 @@
1
+ changewatch/__init__.py,sha256=mlLLTt1_d8JJMRIsrhT6Q2IMjPEgXQnZ6MkSfpBKnps,41085
2
+ changewatcher-1.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
3
+ changewatcher-1.0.0.dist-info/METADATA,sha256=ciKAl4gDBnTOH4fpD0Fk1b83t4uVPXR5Gkol3pkldoQ,2306
4
+ changewatcher-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ changewatcher-1.0.0.dist-info/top_level.txt,sha256=5VlMlvFYsttEROFADeXPv3hcrL6vSzC8bvFwB_t2qvg,12
6
+ changewatcher-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1 @@
1
+ changewatch