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.
changewatch/__init__.py
ADDED
|
@@ -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,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
|