From daf73ac17ea4fbd0bdfbeb90fdb9c7a5f4cdd080 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sun, 11 Oct 2026 08:27:47 +0000 Subject: [PATCH 1/2] Rename to clicktag Package, identifiers, env var, User-Agent and default endpoint move from totallytics to clicktag. TOTALLYTICS_API_KEY stays as a fallback where a published version existed. --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 4 +- README.md | 44 +++++++-------- pyproject.toml | 16 +++--- scripts/generate_fixtures.mts | 10 ++-- scripts/server_payloads.py | 12 ++-- scripts/validate_server.mts | 2 +- src/clicktag/__init__.py | 5 ++ src/{totallytics => clicktag}/_aggregator.py | 0 src/{totallytics => clicktag}/_client.py | 10 ++-- src/{totallytics => clicktag}/_histogram.py | 0 src/{totallytics => clicktag}/_middleware.py | 4 +- src/{totallytics => clicktag}/_runtime.py | 8 +-- src/{totallytics => clicktag}/_transport.py | 4 +- src/{totallytics => clicktag}/_util.py | 0 src/{totallytics => clicktag}/_version.py | 0 src/{totallytics => clicktag}/asgi.py | 4 +- src/clicktag/fastapi.py | 11 ++++ src/{totallytics => clicktag}/flask.py | 8 +-- src/{totallytics => clicktag}/py.typed | 0 src/{totallytics => clicktag}/starlette.py | 6 +- src/{totallytics => clicktag}/wsgi.py | 6 +- src/totallytics/__init__.py | 5 -- src/totallytics/fastapi.py | 11 ---- tests/conftest.py | 8 +-- tests/test_asgi.py | 22 ++++---- tests/test_client.py | 58 ++++++++++---------- tests/test_conformance.py | 6 +- tests/test_core.py | 8 +-- tests/test_fastapi.py | 14 ++--- tests/test_flask.py | 8 +-- tests/test_starlette.py | 8 +-- tests/test_transport.py | 10 ++-- tests/test_worker.py | 10 ++-- tests/test_wsgi.py | 12 ++-- 35 files changed, 170 insertions(+), 170 deletions(-) create mode 100644 src/clicktag/__init__.py rename src/{totallytics => clicktag}/_aggregator.py (100%) rename src/{totallytics => clicktag}/_client.py (97%) rename src/{totallytics => clicktag}/_histogram.py (100%) rename src/{totallytics => clicktag}/_middleware.py (98%) rename src/{totallytics => clicktag}/_runtime.py (72%) rename src/{totallytics => clicktag}/_transport.py (98%) rename src/{totallytics => clicktag}/_util.py (100%) rename src/{totallytics => clicktag}/_version.py (100%) rename src/{totallytics => clicktag}/asgi.py (98%) create mode 100644 src/clicktag/fastapi.py rename src/{totallytics => clicktag}/flask.py (86%) rename src/{totallytics => clicktag}/py.typed (100%) rename src/{totallytics => clicktag}/starlette.py (69%) rename src/{totallytics => clicktag}/wsgi.py (97%) delete mode 100644 src/totallytics/__init__.py delete mode 100644 src/totallytics/fastapi.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb50adc..479b23a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,9 @@ jobs: /tmp/wheel/bin/pip install dist/*.whl /tmp/wheel/bin/python -c " import importlib.metadata as metadata - import totallytics, totallytics.asgi, totallytics.wsgi - assert not [r for r in metadata.requires('totallytics') or [] if 'extra ==' not in r], 'runtime deps found' - print('totallytics', totallytics.__version__) + import clicktag, clicktag.asgi, clicktag.wsgi + assert not [r for r in metadata.requires('clicktag') or [] if 'extra ==' not in r], 'runtime deps found' + print('clicktag', clicktag.__version__) " - uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bd31af..7ad4263 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: python-version: "3.13" - name: Check the tag matches the package version run: | - version=$(PYTHONPATH=src python -c "from totallytics import __version__; print(__version__)") + version=$(PYTHONPATH=src python -c "from clicktag import __version__; print(__version__)") if [ "v$version" != "$GITHUB_REF_NAME" ]; then echo "::error::tag $GITHUB_REF_NAME does not match package version $version" exit 1 @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/totallytics/ + url: https://pypi.org/project/clicktag/ permissions: id-token: write steps: diff --git a/README.md b/README.md index 85ede37..a0e269e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# totallytics +# clicktag -Official Python SDK for [Totallytics](https://totallytics.com) API analytics. Add one middleware and see traffic, latency percentiles, error rates and top consumers per endpoint. +Official Python SDK for [Clicktag](https://clicktag.io) API analytics. Add one middleware and see traffic, latency percentiles, error rates and top consumers per endpoint. - Works with FastAPI, Starlette, Flask and any ASGI or WSGI app - Zero dependencies, Python 3.9+ @@ -9,66 +9,66 @@ Official Python SDK for [Totallytics](https://totallytics.com) API analytics. Ad ## Install ```sh -pip install totallytics +pip install clicktag ``` -Create an API key in your Totallytics dashboard and set it as `TOTALLYTICS_API_KEY`. +Create an API key in your Clicktag dashboard and set it as `CLICKTAG_API_KEY`. ## FastAPI ```python from fastapi import FastAPI -from totallytics.fastapi import TotallyticsMiddleware +from clicktag.fastapi import ClicktagMiddleware app = FastAPI() -app.add_middleware(TotallyticsMiddleware) +app.add_middleware(ClicktagMiddleware) ``` -Starlette works the same way with `from totallytics.starlette import TotallyticsMiddleware`. +Starlette works the same way with `from clicktag.starlette import ClicktagMiddleware`. ## Flask ```python from flask import Flask -from totallytics.flask import TotallyticsMiddleware +from clicktag.flask import ClicktagMiddleware app = Flask(__name__) -TotallyticsMiddleware(app) +ClicktagMiddleware(app) ``` -With an app factory, create `totallytics = TotallyticsMiddleware()` once and call `totallytics.init_app(app)`. +With an app factory, create `clicktag = ClicktagMiddleware()` once and call `clicktag.init_app(app)`. ## Any ASGI or WSGI app ```python -from totallytics.asgi import TotallyticsMiddleware # or totallytics.wsgi +from clicktag.asgi import ClicktagMiddleware # or clicktag.wsgi -app = TotallyticsMiddleware(app) +app = ClicktagMiddleware(app) ``` -This covers Django, Litestar, Quart, Falcon and friends. These frameworks don't expose a route template to middleware, so the raw path is sent and Totallytics groups it into routes (`/users/42` becomes `/users/:id`). Pass a `route` callback to send your own templates. +This covers Django, Litestar, Quart, Falcon and friends. These frameworks don't expose a route template to middleware, so the raw path is sent and Clicktag groups it into routes (`/users/42` becomes `/users/:id`). Pass a `route` callback to send your own templates. ## Options -Pass options as keyword arguments, e.g. `app.add_middleware(TotallyticsMiddleware, consumer=..., ignore=...)`. +Pass options as keyword arguments, e.g. `app.add_middleware(ClicktagMiddleware, consumer=..., ignore=...)`. | Option | Default | Description | | --- | --- | --- | -| `api_key` | `TOTALLYTICS_API_KEY` | API key, or a zero-argument function returning it | +| `api_key` | `CLICKTAG_API_KEY` | API key, or a zero-argument function returning it | | `consumer` | none | `consumer(request)` returns who made the call (customer, tenant, API key id) | | `route` | framework route | `route(request)` returns a route template, `None` keeps the default | | `ignore` | none | `ignore(request)` returns `True` to skip a request, e.g. health checks | -| `endpoint` | `https://totallytics.com/api/ingest` | Ingest URL | +| `endpoint` | `https://clicktag.io/api/ingest` | Ingest URL | | `flush_interval` | `10.0` | Seconds between flushes | | `max_batch_rows` | `1000` | Rows per batch (1 to 5000). A full buffer flushes right away | | `error_samples` | `True` | Send up to 50 5xx and 20 4xx samples per flush | -| `debug` | `False` | Log diagnostics through the `totallytics` logger | +| `debug` | `False` | Log diagnostics through the `clicktag` logger | Callbacks receive a Starlette `Request` (FastAPI, Starlette), `flask.request` (Flask), the ASGI `scope` or the WSGI `environ`. ```python app.add_middleware( - TotallyticsMiddleware, + ClicktagMiddleware, consumer=lambda request: request.headers.get("x-customer-id"), ignore=lambda request: request.url.path == "/health", ) @@ -85,15 +85,15 @@ Never sent: request or response bodies, headers other than User-Agent, query str - **Timing.** Duration runs from the request until the response starts, so streamed bodies don't count. Unhandled exceptions are recorded as 500, and ASGI clients that disconnect before a response as 499. - **Workers.** Every process (gunicorn or uvicorn workers) keeps and flushes its own buffer. Forked workers start empty. - **Shutdown.** Buffers flush on ASGI lifespan shutdown and at interpreter exit, within 5 seconds. -- **Serverless.** A frozen process can't flush in the background, so call `totallytics.flush()` before your handler returns. It sends everything buffered, waits for delivery and returns `False` if its `timeout` ran out. +- **Serverless.** A frozen process can't flush in the background, so call `clicktag.flush()` before your handler returns. It sends everything buffered, waits for delivery and returns `False` if its `timeout` ran out. - **Delivery.** Failed batches are retried up to 3 times with backoff, using the same body and `batch_id` so the server drops duplicates. Oversized batches are split. An invalid key logs one warning per process and data is dropped. ## Custom integrations ```python -from totallytics import Totallytics +from clicktag import Clicktag -client = Totallytics(integration="my-server") +client = Clicktag(integration="my-server") client.record(method="GET", path="/users/42", route="/users/:id", status=200, duration_ms=12.5) ``` @@ -108,7 +108,7 @@ ruff check . && ruff format --check . `tests/fixtures/conformance.json` is generated by the JS SDK, so both SDKs produce identical batches: ```sh -npx tsx scripts/generate_fixtures.mts ../totallytics-js > tests/fixtures/conformance.json +npx tsx scripts/generate_fixtures.mts ../clicktag-js > tests/fixtures/conformance.json ``` ## License diff --git a/pyproject.toml b/pyproject.toml index 7768880..e3a0bec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] -name = "totallytics" +name = "clicktag" dynamic = ["version"] -description = "Official Python SDK for Totallytics API analytics: FastAPI, Starlette, Flask and any ASGI or WSGI app." +description = "Official Python SDK for Clicktag API analytics: FastAPI, Starlette, Flask and any ASGI or WSGI app." readme = "README.md" requires-python = ">=3.9" license = "MIT" @@ -35,18 +35,18 @@ dependencies = [] test = ["pytest>=8", "httpx>=0.27", "fastapi>=0.110", "flask>=2.3"] [project.urls] -Homepage = "https://totallytics.com" -Source = "https://github.com/bitgate/totallytics-python" -Issues = "https://github.com/bitgate/totallytics-python/issues" +Homepage = "https://clicktag.io" +Source = "https://github.com/clicktag-io/clicktag-python" +Issues = "https://github.com/clicktag-io/clicktag-python/issues" [tool.hatch.version] -path = "src/totallytics/_version.py" +path = "src/clicktag/_version.py" [tool.hatch.build.targets.wheel] -packages = ["src/totallytics"] +packages = ["src/clicktag"] [tool.hatch.build.targets.sdist] -include = ["src/totallytics", "tests", "README.md", "LICENSE"] +include = ["src/clicktag", "tests", "README.md", "LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/generate_fixtures.mts b/scripts/generate_fixtures.mts index 0e6b4b0..6b3fcc4 100644 --- a/scripts/generate_fixtures.mts +++ b/scripts/generate_fixtures.mts @@ -1,13 +1,13 @@ // Regenerates tests/fixtures/conformance.json from the JS SDK, the reference implementation. -// Usage: npx tsx scripts/generate_fixtures.mts ../totallytics-js > tests/fixtures/conformance.json +// Usage: npx tsx scripts/generate_fixtures.mts ../clicktag-js > tests/fixtures/conformance.json import path from 'node:path' import { pathToFileURL } from 'node:url' const root = process.argv[2] -if (!root) throw new Error('pass the path to a totallytics-js checkout') +if (!root) throw new Error('pass the path to a clicktag-js checkout') const load = (file: string) => import(pathToFileURL(path.resolve(root, file)).href) -const { Totallytics } = await load('src/core/client.ts') +const { Clicktag } = await load('src/core/client.ts') const { bucket } = await load('src/core/histogram.ts') const { VERSION } = await load('src/version.ts') @@ -37,7 +37,7 @@ async function scenario(name: string, options: Entry, entries: Entry[], respond: return new Response('{}', { status }) }) as typeof fetch - const client = new Totallytics({ apiKey: KEY, ...options }) + const client = new Clicktag({ apiKey: KEY, ...options }) for (const entry of entries) client.record(entry) await client.flush() return { name, options, entries: entries.map(encodeEntry), batches } @@ -198,4 +198,4 @@ const scenarios = [ await scenario('413 split', {}, split, (payload) => (payload.metrics.length + payload.errors.length > 3 ? 413 : 202)), ] -process.stdout.write(`${JSON.stringify({ source: `totallytics-js ${VERSION}`, buckets: bucketCases(), scenarios })}\n`) +process.stdout.write(`${JSON.stringify({ source: `clicktag-js ${VERSION}`, buckets: bucketCases(), scenarios })}\n`) diff --git a/scripts/server_payloads.py b/scripts/server_payloads.py index c2f4912..c618683 100644 --- a/scripts/server_payloads.py +++ b/scripts/server_payloads.py @@ -1,7 +1,7 @@ """Prints the exact request bodies the SDK sends for the conformance scenarios plus real FastAPI and Flask traffic. Usage: PYTHONPATH=. python scripts/server_payloads.py > payloads.json -Then: npx tsx scripts/validate_server.mts payloads.json +Then: npx tsx scripts/validate_server.mts payloads.json """ from __future__ import annotations @@ -11,10 +11,10 @@ import time import warnings -import totallytics +import clicktag +from clicktag import Clicktag, _transport from tests.conftest import KEY from tests.test_conformance import FIXTURE, RESPONDERS, arguments -from totallytics import Totallytics, _transport FIXTURE_MINUTE_MS = 1_790_424_000_000 bodies: list[str] = [] @@ -32,7 +32,7 @@ def replay_scenarios() -> None: if scenario["name"] in RESPONDERS: continue options = scenario["options"] - client = Totallytics( + client = Clicktag( KEY, max_batch_rows=options.get("maxBatchRows"), error_samples=options.get("errorSamples", True) ) for entry in scenario["entries"]: @@ -44,7 +44,7 @@ def replay_scenarios() -> None: def record_extremes() -> None: - client = Totallytics(KEY, max_batch_rows=5_000) + client = Clicktag(KEY, max_batch_rows=5_000) for index in range(5_000): client.record( method=("GET", "POST", "DELETE", "PATCH")[index % 4], @@ -71,7 +71,7 @@ def replay_frameworks() -> None: for path in ("/users/1?token=x", "/files/a/b.txt", "/v1/orgs/acme/items/7", "/boom", "/nope/😀", "/stream"): client.get(path, headers={"User-Agent": "curl/8.4.0"}) client.post("/users/1") - totallytics.flush() + clicktag.flush() if __name__ == "__main__": diff --git a/scripts/validate_server.mts b/scripts/validate_server.mts index 7a012a5..5574790 100644 --- a/scripts/validate_server.mts +++ b/scripts/validate_server.mts @@ -1,5 +1,5 @@ // Feeds SDK request bodies through the server's own normalizeBatch and fails on any rejected row. -// Usage: npx tsx scripts/validate_server.mts payloads.json +// Usage: npx tsx scripts/validate_server.mts payloads.json import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' diff --git a/src/clicktag/__init__.py b/src/clicktag/__init__.py new file mode 100644 index 0000000..ef9baf7 --- /dev/null +++ b/src/clicktag/__init__.py @@ -0,0 +1,5 @@ +from ._client import DEFAULT_ENDPOINT, Clicktag, flush +from ._histogram import bucket +from ._version import __version__ + +__all__ = ["DEFAULT_ENDPOINT", "Clicktag", "__version__", "bucket", "flush"] diff --git a/src/totallytics/_aggregator.py b/src/clicktag/_aggregator.py similarity index 100% rename from src/totallytics/_aggregator.py rename to src/clicktag/_aggregator.py diff --git a/src/totallytics/_client.py b/src/clicktag/_client.py similarity index 97% rename from src/totallytics/_client.py rename to src/clicktag/_client.py index 6dd73ff..571af16 100644 --- a/src/totallytics/_client.py +++ b/src/clicktag/_client.py @@ -13,7 +13,7 @@ from ._transport import USER_AGENT, Transport, create_batch from ._util import clip, describe_error, path_of -DEFAULT_ENDPOINT = "https://totallytics.com/api/ingest" +DEFAULT_ENDPOINT = "https://clicktag.io/api/ingest" MAX_SDK = 64 MAX_PATH = 512 @@ -22,11 +22,11 @@ MAX_MESSAGE = 1000 SHUTDOWN_BUDGET_SECONDS = 5.0 -_clients: weakref.WeakSet[Totallytics] = weakref.WeakSet() +_clients: weakref.WeakSet[Clicktag] = weakref.WeakSet() -class Totallytics: - """Aggregates finished requests per minute and ships them to Totallytics from one background thread.""" +class Clicktag: + """Aggregates finished requests per minute and ships them to Clicktag from one background thread.""" def __init__( self, @@ -130,7 +130,7 @@ def shutdown(self, timeout: float | None = SHUTDOWN_BUDGET_SECONDS) -> bool: return delivered def _start_worker(self) -> None: - worker = threading.Thread(target=self._work, args=(self._generation,), name="totallytics", daemon=True) + worker = threading.Thread(target=self._work, args=(self._generation,), name="clicktag", daemon=True) worker.start() self._worker = worker diff --git a/src/totallytics/_histogram.py b/src/clicktag/_histogram.py similarity index 100% rename from src/totallytics/_histogram.py rename to src/clicktag/_histogram.py diff --git a/src/totallytics/_middleware.py b/src/clicktag/_middleware.py similarity index 98% rename from src/totallytics/_middleware.py rename to src/clicktag/_middleware.py index 70ae22c..22242a4 100644 --- a/src/totallytics/_middleware.py +++ b/src/clicktag/_middleware.py @@ -3,7 +3,7 @@ import re from typing import Any, Callable, Optional, Union -from ._client import Totallytics +from ._client import Clicktag from ._runtime import default_key, normalize_key, warn, warn_no_key from ._util import attempt, describe_error @@ -36,7 +36,7 @@ def __init__( self.consumer = consumer self.route = route self.ignore = ignore - self.client = Totallytics( + self.client = Clicktag( None if callable(api_key) else api_key, endpoint=endpoint, max_batch_rows=max_batch_rows, diff --git a/src/totallytics/_runtime.py b/src/clicktag/_runtime.py similarity index 72% rename from src/totallytics/_runtime.py rename to src/clicktag/_runtime.py index 92f605b..4759fbc 100644 --- a/src/totallytics/_runtime.py +++ b/src/clicktag/_runtime.py @@ -4,7 +4,7 @@ import os import threading -logger = logging.getLogger("totallytics") +logger = logging.getLogger("clicktag") _warned: set[str] = set() _warned_lock = threading.Lock() @@ -17,11 +17,11 @@ def normalize_key(value: object) -> str | None: def default_key() -> str | None: - return normalize_key(os.environ.get("TOTALLYTICS_API_KEY")) + return normalize_key(os.environ.get("CLICKTAG_API_KEY")) def warn(message: str) -> None: - logger.warning("[totallytics] %s", message) + logger.warning("[clicktag] %s", message) def warn_once(warning_id: str, message: str) -> None: @@ -34,7 +34,7 @@ def warn_once(warning_id: str, message: str) -> None: def warn_no_key(debug: bool) -> None: if debug: - warn_once("no-key", "no API key (set TOTALLYTICS_API_KEY or pass api_key), requests are not recorded") + warn_once("no-key", "no API key (set CLICKTAG_API_KEY or pass api_key), requests are not recorded") def reset_after_fork() -> None: diff --git a/src/totallytics/_transport.py b/src/clicktag/_transport.py similarity index 98% rename from src/totallytics/_transport.py rename to src/clicktag/_transport.py index aada8ce..2df1755 100644 --- a/src/totallytics/_transport.py +++ b/src/clicktag/_transport.py @@ -14,7 +14,7 @@ from ._util import describe_error from ._version import __version__ -USER_AGENT = f"totallytics-python/{__version__}" +USER_AGENT = f"clicktag-python/{__version__}" MAX_ATTEMPTS = 3 MAX_PENDING = 20 @@ -193,7 +193,7 @@ def _send(self, batch: Batch, timeout: float) -> str: if status == 401: warn_once( "unauthorized", - "ingest rejected the API key (401), analytics are being dropped. Check TOTALLYTICS_API_KEY.", + "ingest rejected the API key (401), analytics are being dropped. Check CLICKTAG_API_KEY.", ) return "drop" if status == 413: diff --git a/src/totallytics/_util.py b/src/clicktag/_util.py similarity index 100% rename from src/totallytics/_util.py rename to src/clicktag/_util.py diff --git a/src/totallytics/_version.py b/src/clicktag/_version.py similarity index 100% rename from src/totallytics/_version.py rename to src/clicktag/_version.py diff --git a/src/totallytics/asgi.py b/src/clicktag/asgi.py similarity index 98% rename from src/totallytics/asgi.py rename to src/clicktag/asgi.py index f256b9f..84a0d8d 100644 --- a/src/totallytics/asgi.py +++ b/src/clicktag/asgi.py @@ -20,7 +20,7 @@ CLIENT_CLOSED_REQUEST = 499 SHUTDOWN_MESSAGES = ("lifespan.shutdown.complete", "lifespan.shutdown.failed") -__all__ = ["TotallyticsMiddleware"] +__all__ = ["ClicktagMiddleware"] class _Exchange: @@ -39,7 +39,7 @@ def elapsed_ms(self) -> float: return (time.perf_counter() - self.start) * 1000 -class TotallyticsMiddleware(Middleware): +class ClicktagMiddleware(Middleware): """Records every HTTP request. Duration runs until the response starts, so streamed bodies are excluded.""" integration = "asgi" diff --git a/src/clicktag/fastapi.py b/src/clicktag/fastapi.py new file mode 100644 index 0000000..d6e4cdb --- /dev/null +++ b/src/clicktag/fastapi.py @@ -0,0 +1,11 @@ +"""FastAPI middleware. Callbacks receive a `fastapi.Request`.""" + +from __future__ import annotations + +from .starlette import ClicktagMiddleware as _StarletteMiddleware + +__all__ = ["ClicktagMiddleware"] + + +class ClicktagMiddleware(_StarletteMiddleware): + integration = "fastapi" diff --git a/src/totallytics/flask.py b/src/clicktag/flask.py similarity index 86% rename from src/totallytics/flask.py rename to src/clicktag/flask.py index f792ff8..839c753 100644 --- a/src/totallytics/flask.py +++ b/src/clicktag/flask.py @@ -8,13 +8,13 @@ from ._middleware import ApiKey, join_route, werkzeug_template from .wsgi import EXCHANGE_KEY, Environ -from .wsgi import TotallyticsMiddleware as _WSGIMiddleware +from .wsgi import ClicktagMiddleware as _WSGIMiddleware -__all__ = ["TotallyticsMiddleware"] +__all__ = ["ClicktagMiddleware"] -class TotallyticsMiddleware(_WSGIMiddleware): - """`TotallyticsMiddleware(app)` or `init_app(app)`; wraps `app.wsgi_app`.""" +class ClicktagMiddleware(_WSGIMiddleware): + """`ClicktagMiddleware(app)` or `init_app(app)`; wraps `app.wsgi_app`.""" integration = "flask" diff --git a/src/totallytics/py.typed b/src/clicktag/py.typed similarity index 100% rename from src/totallytics/py.typed rename to src/clicktag/py.typed diff --git a/src/totallytics/starlette.py b/src/clicktag/starlette.py similarity index 69% rename from src/totallytics/starlette.py rename to src/clicktag/starlette.py index aa2dd48..944e1c1 100644 --- a/src/totallytics/starlette.py +++ b/src/clicktag/starlette.py @@ -6,13 +6,13 @@ from starlette.requests import Request +from .asgi import ClicktagMiddleware as _ASGIMiddleware from .asgi import Scope -from .asgi import TotallyticsMiddleware as _ASGIMiddleware -__all__ = ["TotallyticsMiddleware"] +__all__ = ["ClicktagMiddleware"] -class TotallyticsMiddleware(_ASGIMiddleware): +class ClicktagMiddleware(_ASGIMiddleware): integration = "starlette" def _callback_target(self, scope: Scope) -> Any: diff --git a/src/totallytics/wsgi.py b/src/clicktag/wsgi.py similarity index 97% rename from src/totallytics/wsgi.py rename to src/clicktag/wsgi.py index 9fc26da..ce59212 100644 --- a/src/totallytics/wsgi.py +++ b/src/clicktag/wsgi.py @@ -13,9 +13,9 @@ StartResponse = Callable[..., Any] WSGIApp = Callable[[Environ, StartResponse], Iterable[bytes]] -EXCHANGE_KEY = "totallytics.exchange" +EXCHANGE_KEY = "clicktag.exchange" -__all__ = ["TotallyticsMiddleware"] +__all__ = ["ClicktagMiddleware"] class _Exchange: @@ -37,7 +37,7 @@ def elapsed_ms(self) -> float: return (time.perf_counter() - self.start) * 1000 -class TotallyticsMiddleware(Middleware): +class ClicktagMiddleware(Middleware): """Records every request. Duration runs until `start_response`, so streamed bodies are excluded.""" integration = "wsgi" diff --git a/src/totallytics/__init__.py b/src/totallytics/__init__.py deleted file mode 100644 index 06f34d8..0000000 --- a/src/totallytics/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from ._client import DEFAULT_ENDPOINT, Totallytics, flush -from ._histogram import bucket -from ._version import __version__ - -__all__ = ["DEFAULT_ENDPOINT", "Totallytics", "__version__", "bucket", "flush"] diff --git a/src/totallytics/fastapi.py b/src/totallytics/fastapi.py deleted file mode 100644 index 755db8b..0000000 --- a/src/totallytics/fastapi.py +++ /dev/null @@ -1,11 +0,0 @@ -"""FastAPI middleware. Callbacks receive a `fastapi.Request`.""" - -from __future__ import annotations - -from .starlette import TotallyticsMiddleware as _StarletteMiddleware - -__all__ = ["TotallyticsMiddleware"] - - -class TotallyticsMiddleware(_StarletteMiddleware): - integration = "fastapi" diff --git a/tests/conftest.py b/tests/conftest.py index b2981cb..02be9fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pytest -from totallytics import Totallytics, _client, _runtime, _transport +from clicktag import Clicktag, _client, _runtime, _transport KEY = "tt_" + "ab" * 24 @@ -63,16 +63,16 @@ def ingest(monkeypatch: pytest.MonkeyPatch) -> Ingest: @pytest.fixture(autouse=True) def isolated(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, ingest: Ingest) -> None: monkeypatch.setattr(_transport, "backoff", lambda attempt: 0.0) - monkeypatch.delenv("TOTALLYTICS_API_KEY", raising=False) + monkeypatch.delenv("CLICKTAG_API_KEY", raising=False) monkeypatch.setattr(_runtime, "_warned", set()) monkeypatch.setattr(_client, "_clients", weakref.WeakSet()) # We keep the background thread out of tests unless they are about it if request.node.get_closest_marker("worker") is None: - monkeypatch.setattr(Totallytics, "_start_worker", lambda self: None) + monkeypatch.setattr(Clicktag, "_start_worker", lambda self: None) -def record(client: Totallytics, **overrides: Any) -> None: +def record(client: Clicktag, **overrides: Any) -> None: entry: dict[str, Any] = { "method": "GET", "path": "/users/42", diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 58c2f83..6c6eb37 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -7,7 +7,7 @@ import pytest -from totallytics.asgi import TotallyticsMiddleware +from clicktag.asgi import ClicktagMiddleware from .conftest import KEY, Ingest, errors_of, metrics_of @@ -54,7 +54,7 @@ async def app(scope: dict, receive: Any, send: Any) -> None: def test_records_requests_until_the_response_starts(ingest: Ingest) -> None: - middleware = TotallyticsMiddleware(responding(201, delay=0.3), api_key=KEY) + middleware = ClicktagMiddleware(responding(201, delay=0.3), api_key=KEY) sent = call(middleware, http_scope()) assert [message["type"] for message in sent] == ["http.response.start", "http.response.body"] middleware.flush() @@ -74,7 +74,7 @@ async def app(scope: dict, receive: Any, send: Any) -> None: scope["route"] = route await responding()(scope, receive, send) - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) call(middleware, http_scope()) middleware.flush() assert metrics_of(ingest)[0]["route"] == "/things/:thing_id" @@ -84,7 +84,7 @@ def test_records_exceptions_as_500_and_reraises(ingest: Ingest) -> None: async def app(scope: dict, receive: Any, send: Any) -> None: raise ValueError("boom") - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) with pytest.raises(ValueError): call(middleware, http_scope()) middleware.flush() @@ -99,7 +99,7 @@ async def app(scope: dict, receive: Any, send: Any) -> None: while (await receive())["type"] != "http.disconnect": pass - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) call(middleware, http_scope()) middleware.flush() assert metrics_of(ingest)[0]["status"] == 499 @@ -109,7 +109,7 @@ def test_records_499_when_the_request_is_cancelled(ingest: Ingest) -> None: async def app(scope: dict, receive: Any, send: Any) -> None: raise asyncio.CancelledError - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) with pytest.raises(asyncio.CancelledError): call(middleware, http_scope()) middleware.flush() @@ -120,7 +120,7 @@ def test_callbacks_receive_the_scope(ingest: Ingest) -> None: def consumer(scope: dict) -> str: return dict(scope["headers"])[b"x-org"].decode() - middleware = TotallyticsMiddleware( + middleware = ClicktagMiddleware( responding(), api_key=lambda: KEY, consumer=consumer, @@ -141,9 +141,9 @@ def test_failing_callbacks_never_break_requests(ingest: Ingest) -> None: def explode(value: object = None) -> str: raise RuntimeError("nope") - middleware = TotallyticsMiddleware(responding(), api_key=KEY, consumer=explode, route=explode, ignore=explode) + middleware = ClicktagMiddleware(responding(), api_key=KEY, consumer=explode, route=explode, ignore=explode) call(middleware, http_scope()) - unkeyed = TotallyticsMiddleware(responding(), api_key=explode) + unkeyed = ClicktagMiddleware(responding(), api_key=explode) call(unkeyed, http_scope()) middleware.flush() unkeyed.flush() @@ -159,7 +159,7 @@ def test_passes_other_scopes_through(ingest: Ingest) -> None: async def app(scope: dict, receive: Any, send: Any) -> None: seen.append(scope["type"]) - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) call(middleware, {"type": "websocket", "path": "/ws"}) middleware.flush() assert seen == ["websocket"] @@ -181,7 +181,7 @@ async def app(scope: dict, receive: Any, send: Any) -> None: await send({"type": "lifespan.shutdown.complete"}) return - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) call(middleware, http_scope()) async def run_lifespan() -> None: diff --git a/tests/test_client.py b/tests/test_client.py index 307dc9f..73af040 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,15 +8,15 @@ import pytest -import totallytics -from totallytics import DEFAULT_ENDPOINT, Totallytics, __version__, _client -from totallytics._transport import MAX_ATTEMPTS, MAX_PENDING +import clicktag +from clicktag import DEFAULT_ENDPOINT, Clicktag, __version__, _client +from clicktag._transport import MAX_ATTEMPTS, MAX_PENDING from .conftest import KEY, Ingest, errors_of, metrics_of, record def test_sends_the_wire_payload(ingest: Ingest) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) record(client, user_agent="curl/8.4", consumer="acme") assert client.flush() @@ -28,7 +28,7 @@ def test_sends_the_wire_payload(ingest: Ingest) -> None: assert call.payload == { "v": 1, "batch_id": call.payload["batch_id"], - "sdk": f"totallytics-python/{__version__}", + "sdk": f"clicktag-python/{__version__}", "metrics": [ { "minute": 1_790_424_000, @@ -47,15 +47,15 @@ def test_sends_the_wire_payload(ingest: Ingest) -> None: def test_reads_the_key_from_the_environment(ingest: Ingest, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("TOTALLYTICS_API_KEY", f" {KEY} ") - client = Totallytics() + monkeypatch.setenv("CLICKTAG_API_KEY", f" {KEY} ") + client = Clicktag() record(client) client.flush() assert [call.api_key for call in ingest.calls] == [KEY] def test_does_nothing_without_a_key(ingest: Ingest, caplog: pytest.LogCaptureFixture) -> None: - client = Totallytics(" ") + client = Clicktag(" ") record(client) assert client.flush() assert ingest.calls == [] @@ -63,16 +63,16 @@ def test_does_nothing_without_a_key(ingest: Ingest, caplog: pytest.LogCaptureFix def test_debug_explains_a_missing_key_once(caplog: pytest.LogCaptureFixture) -> None: - client = Totallytics(debug=True) + client = Clicktag(debug=True) record(client) record(client) assert [record.getMessage() for record in caplog.records] == [ - "[totallytics] no API key (set TOTALLYTICS_API_KEY or pass api_key), requests are not recorded" + "[clicktag] no API key (set CLICKTAG_API_KEY or pass api_key), requests are not recorded" ] def test_falls_back_to_the_raw_path_and_clips_fields(ingest: Ingest) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) record( client, route=None, @@ -95,7 +95,7 @@ def test_falls_back_to_the_raw_path_and_clips_fields(ingest: Ingest) -> None: @pytest.mark.parametrize("status", [99, 600, 200.5, True, "200", None, math.nan]) def test_ignores_invalid_statuses(ingest: Ingest, status: object) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) record(client, status=status) client.flush() assert ingest.calls == [] @@ -103,7 +103,7 @@ def test_ignores_invalid_statuses(ingest: Ingest, status: object) -> None: @pytest.mark.parametrize("duration_ms", [-5, 0, math.nan, math.inf, None, "12"]) def test_records_unusable_durations_as_zero(ingest: Ingest, duration_ms: object) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) record(client, duration_ms=duration_ms) client.flush() @@ -117,7 +117,7 @@ class Unprintable: def __str__(self) -> str: raise RuntimeError("no") - client = Totallytics(KEY) + client = Clicktag(KEY) record(client, path=Unprintable()) record(client, consumer=Unprintable()) record(client, method=Unprintable()) @@ -127,7 +127,7 @@ def __str__(self) -> str: def test_splits_large_buffers_by_max_batch_rows(ingest: Ingest) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) for index in range(2_500): record(client, route=f"/r/{index}", status=500 if index < 3 else 200) client.flush() @@ -142,25 +142,25 @@ def test_splits_large_buffers_by_max_batch_rows(ingest: Ingest) -> None: [(None, 1_000, 10.0), (0, 1, 0.1), (10_000, 5_000, 3_600.0), (2.7, 2, 2.7), ("x", 1_000, 10.0)], ) def test_clamps_options(given: object, rows: int, interval: float) -> None: - client = Totallytics(KEY, max_batch_rows=given, flush_interval=given) # type: ignore[arg-type] + client = Clicktag(KEY, max_batch_rows=given, flush_interval=given) # type: ignore[arg-type] assert client.max_batch_rows == rows assert client.flush_interval == interval def test_sdk_names_the_integration() -> None: - assert Totallytics(KEY, integration="fastapi").sdk == f"totallytics-python/{__version__} fastapi" + assert Clicktag(KEY, integration="fastapi").sdk == f"clicktag-python/{__version__} fastapi" def test_module_flush_covers_every_client(ingest: Ingest) -> None: - first, second = Totallytics(KEY), Totallytics(KEY) + first, second = Clicktag(KEY), Clicktag(KEY) record(first) record(second) - assert totallytics.flush(5) + assert clicktag.flush(5) assert len(ingest.calls) == 2 def test_forked_children_start_with_an_empty_buffer(ingest: Ingest) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) _client._reset_after_fork() client.flush() @@ -169,7 +169,7 @@ def test_forked_children_start_with_an_empty_buffer(ingest: Ingest) -> None: def test_retries_send_the_identical_body(ingest: Ingest) -> None: ingest.statuses = [503, urllib.error.URLError("down"), 202] - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) assert client.flush() @@ -180,7 +180,7 @@ def test_retries_send_the_identical_body(ingest: Ingest) -> None: @pytest.mark.parametrize("outcome", [408, 429, 500, 503, TimeoutError(), RuntimeError("bug")]) def test_gives_up_after_three_attempts(ingest: Ingest, outcome: object) -> None: ingest.respond = lambda payload: outcome # type: ignore[assignment, return-value] - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) assert client.flush() assert len(ingest.calls) == MAX_ATTEMPTS @@ -189,7 +189,7 @@ def test_gives_up_after_three_attempts(ingest: Ingest, outcome: object) -> None: @pytest.mark.parametrize("status", [400, 404, 422]) def test_drops_rejected_batches(ingest: Ingest, status: int) -> None: ingest.statuses = [status] - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) assert client.flush() assert len(ingest.calls) == 1 @@ -198,20 +198,20 @@ def test_drops_rejected_batches(ingest: Ingest, status: int) -> None: def test_warns_about_401_once_per_process(ingest: Ingest, caplog: pytest.LogCaptureFixture) -> None: ingest.respond = lambda payload: 401 for _ in range(2): - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) client.flush() assert len(ingest.calls) == 2 warnings = [entry for entry in caplog.records if entry.levelno == logging.WARNING] assert [entry.getMessage() for entry in warnings] == [ - "[totallytics] ingest rejected the API key (401), analytics are being dropped. Check TOTALLYTICS_API_KEY." + "[clicktag] ingest rejected the API key (401), analytics are being dropped. Check CLICKTAG_API_KEY." ] def test_splits_413_batches_into_new_batches(ingest: Ingest) -> None: ingest.respond = lambda payload: 413 if len(payload["metrics"]) + len(payload["errors"]) > 2 else 202 - client = Totallytics(KEY) + client = Clicktag(KEY) for index in range(5): record(client, route=f"/r/{index}", status=500 if index < 3 else 200) client.flush() @@ -228,14 +228,14 @@ def test_splits_413_batches_into_new_batches(ingest: Ingest) -> None: def test_drops_a_413_batch_that_cannot_be_split(ingest: Ingest) -> None: ingest.respond = lambda payload: 413 - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) assert client.flush() assert len(ingest.calls) == 1 def test_keeps_at_most_20_pending_batches(ingest: Ingest) -> None: - client = Totallytics(KEY) + client = Clicktag(KEY) for index in range(MAX_PENDING + 1): record(client, route=f"/r/{index}") client._flush_buffer() @@ -252,7 +252,7 @@ def slow(payload: object) -> int: return 503 ingest.respond = slow - client = Totallytics(KEY) + client = Clicktag(KEY) record(client) started = time.monotonic() diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 3a970a6..77afa62 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -8,7 +8,7 @@ import pytest -from totallytics import Totallytics, bucket +from clicktag import Clicktag, bucket from .conftest import KEY, Ingest @@ -51,7 +51,7 @@ def by_rows(batches: list[dict[str, Any]]) -> list[dict[str, Any]]: def test_fixture_comes_from_the_js_sdk() -> None: - assert FIXTURE["source"].startswith("totallytics-js ") + assert FIXTURE["source"].startswith(("clicktag-js ", "totallytics-js ")) assert len(FIXTURE["buckets"]) > 2000 assert len(FIXTURE["scenarios"]) == 9 @@ -68,7 +68,7 @@ def test_buckets_match_js() -> None: @pytest.mark.parametrize("scenario", FIXTURE["scenarios"], ids=lambda scenario: scenario["name"]) def test_payloads_match_js(scenario: dict[str, Any], ingest: Ingest) -> None: options = scenario["options"] - client = Totallytics( + client = Clicktag( KEY, max_batch_rows=options.get("maxBatchRows"), error_samples=options.get("errorSamples", True), diff --git a/tests/test_core.py b/tests/test_core.py index 631f634..387b859 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,10 +4,10 @@ import pytest -from totallytics import bucket -from totallytics._aggregator import MAX_KEYS, Aggregator, Measurement -from totallytics._histogram import _fdlibm_log -from totallytics._util import clip, describe_error, path_of, round_ms +from clicktag import bucket +from clicktag._aggregator import MAX_KEYS, Aggregator, Measurement +from clicktag._histogram import _fdlibm_log +from clicktag._util import clip, describe_error, path_of, round_ms MINUTE_MS = 1_790_424_000_000 diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 1133bd1..1c3b222 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -12,8 +12,8 @@ from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient -import totallytics -from totallytics.fastapi import TotallyticsMiddleware +import clicktag +from clicktag.fastapi import ClicktagMiddleware from .conftest import KEY, Ingest, errors_of, metrics_of @@ -59,7 +59,7 @@ def deep(name: str) -> dict: return {} app.mount("/sub", sub) - app.add_middleware(TotallyticsMiddleware, api_key=KEY, **options) + app.add_middleware(ClicktagMiddleware, api_key=KEY, **options) return app @@ -75,7 +75,7 @@ def test_reports_route_templates(ingest: Ingest) -> None: client.get("/sub/deep/x") client.get("/nope/123") client.post("/users/42") - totallytics.flush() + clicktag.flush() assert routes_of(ingest) == [ ("GET", "/files/:file_path", 200), @@ -92,7 +92,7 @@ def test_samples_errors_without_query_strings(ingest: Ingest) -> None: client = TestClient(build_app(), raise_server_exceptions=False) assert client.get("/boom?token=secret").status_code == 500 assert client.get("/forbidden").status_code == 403 - totallytics.flush() + clicktag.flush() assert [(row["route"], row["path"], row["status"], row.get("message")) for row in errors_of(ingest)] == [ ("/boom", "/boom", 500, "ValueError: boom"), @@ -103,7 +103,7 @@ def test_samples_errors_without_query_strings(ingest: Ingest) -> None: def test_excludes_streaming_time(ingest: Ingest) -> None: client = TestClient(build_app()) assert client.get("/stream").content == b"firstsecond" - totallytics.flush() + clicktag.flush() assert metrics_of(ingest)[0]["duration_ms_sum"] < 250 @@ -114,7 +114,7 @@ def consumer(request: Request) -> str | None: client = TestClient(build_app(consumer=consumer, ignore=lambda request: request.url.path.startswith("/files"))) client.get("/users/1", headers={"x-org": "acme"}) client.get("/files/secret.txt") - totallytics.flush() + clicktag.flush() (metric,) = metrics_of(ingest) assert (metric["route"], metric["consumer"]) == ("/users/:user_id", "acme") diff --git a/tests/test_flask.py b/tests/test_flask.py index a4381d6..586682d 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -9,7 +9,7 @@ from flask import Blueprint, Flask, Request, Response, abort -from totallytics.flask import TotallyticsMiddleware +from clicktag.flask import ClicktagMiddleware from .conftest import KEY, Ingest, errors_of, metrics_of @@ -18,7 +18,7 @@ class Teapot(Exception): pass -def build_app(testing: bool = False, **options: Any) -> tuple[Flask, TotallyticsMiddleware]: +def build_app(testing: bool = False, **options: Any) -> tuple[Flask, ClicktagMiddleware]: app = Flask(__name__) app.testing = testing orgs = Blueprint("orgs", __name__, url_prefix="/v1/orgs/") @@ -61,7 +61,7 @@ def chunks() -> Any: return Response(chunks()) app.register_blueprint(orgs) - extension = TotallyticsMiddleware(api_key=KEY, **options) + extension = ClicktagMiddleware(api_key=KEY, **options) extension.init_app(app) return app, extension @@ -138,7 +138,7 @@ def consumer(request: Request) -> str | None: def test_wraps_the_app_directly(ingest: Ingest) -> None: app = Flask(__name__) app.add_url_rule("/ping", "ping", lambda: "pong") - extension = TotallyticsMiddleware(app, api_key=KEY) + extension = ClicktagMiddleware(app, api_key=KEY) app.test_client().get("/ping") extension.flush() assert metrics_of(ingest)[0]["route"] == "/ping" diff --git a/tests/test_starlette.py b/tests/test_starlette.py index 5ac385b..084d97a 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -12,8 +12,8 @@ from starlette.routing import Mount, Route from starlette.testclient import TestClient -import totallytics -from totallytics.starlette import TotallyticsMiddleware +import clicktag +from clicktag.starlette import ClicktagMiddleware from .conftest import KEY, Ingest, metrics_of @@ -32,12 +32,12 @@ def test_reports_route_templates(ingest: Ingest) -> None: Mount("/m/{tenant}", routes=[Route("/x/{item}", ok)]), ] ) - app.add_middleware(TotallyticsMiddleware, api_key=KEY, consumer=lambda request: request.path_params.get("tenant")) + app.add_middleware(ClicktagMiddleware, api_key=KEY, consumer=lambda request: request.path_params.get("tenant")) client = TestClient(app) client.get("/r/7") client.get("/m/acme/x/9") client.get("/m/acme/nope") - totallytics.flush() + clicktag.flush() expected = [ ("/m/:tenant/:path", 404, "acme"), diff --git a/tests/test_transport.py b/tests/test_transport.py index a5e519f..186039d 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -11,8 +11,8 @@ import pytest -import totallytics._transport as transport -from totallytics import Totallytics, __version__ +import clicktag._transport as transport +from clicktag import Clicktag, __version__ from .conftest import KEY, record @@ -54,7 +54,7 @@ def server(monkeypatch: pytest.MonkeyPatch) -> Iterator[Server]: def test_posts_json_with_bearer_auth_and_user_agent(server: Server) -> None: - client = Totallytics(KEY, endpoint=server.url, integration="test") + client = Clicktag(KEY, endpoint=server.url, integration="test") record(client) assert client.flush(5) @@ -62,8 +62,8 @@ def test_posts_json_with_bearer_auth_and_user_agent(server: Server) -> None: assert request["path"] == "/api/ingest" assert request["headers"]["Authorization"] == f"Bearer {KEY}" assert request["headers"]["Content-Type"] == "application/json" - assert request["headers"]["User-Agent"] == f"totallytics-python/{__version__}" - assert json.loads(request["body"])["sdk"] == f"totallytics-python/{__version__} test" + assert request["headers"]["User-Agent"] == f"clicktag-python/{__version__}" + assert json.loads(request["body"])["sdk"] == f"clicktag-python/{__version__} test" def test_reads_error_statuses(server: Server) -> None: diff --git a/tests/test_worker.py b/tests/test_worker.py index e1a2b3b..42783f8 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -2,7 +2,7 @@ import pytest -from totallytics import Totallytics +from clicktag import Clicktag from .conftest import KEY, Ingest, record, wait_for @@ -10,14 +10,14 @@ def test_flushes_on_the_interval(ingest: Ingest) -> None: - client = Totallytics(KEY, flush_interval=0.1) + client = Clicktag(KEY, flush_interval=0.1) record(client) assert wait_for(lambda: len(ingest.calls) == 1) client.shutdown() def test_flushes_early_when_the_buffer_is_full(ingest: Ingest) -> None: - client = Totallytics(KEY, max_batch_rows=5, flush_interval=3_600) + client = Clicktag(KEY, max_batch_rows=5, flush_interval=3_600) for index in range(5): record(client, route=f"/r/{index}") @@ -28,7 +28,7 @@ def test_flushes_early_when_the_buffer_is_full(ingest: Ingest) -> None: def test_retries_in_the_background(ingest: Ingest) -> None: ingest.statuses = [503, 503] - client = Totallytics(KEY, flush_interval=0.1) + client = Clicktag(KEY, flush_interval=0.1) record(client) assert wait_for(lambda: len(ingest.accepted) == 1) assert len(ingest.calls) == 3 @@ -36,7 +36,7 @@ def test_retries_in_the_background(ingest: Ingest) -> None: def test_shutdown_flushes_and_stops_the_thread(ingest: Ingest) -> None: - client = Totallytics(KEY, flush_interval=3_600) + client = Clicktag(KEY, flush_interval=3_600) record(client) worker = client._worker assert worker is not None and worker.is_alive() diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index b255bc4..260e271 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -6,7 +6,7 @@ import pytest -from totallytics.wsgi import TotallyticsMiddleware +from clicktag.wsgi import ClicktagMiddleware from .conftest import KEY, Ingest, errors_of, metrics_of @@ -40,7 +40,7 @@ def hello(env: dict, start_response: Callable[..., Any]) -> list[bytes]: def test_records_requests(ingest: Ingest) -> None: - middleware = TotallyticsMiddleware(hello, api_key=KEY, consumer=lambda env: env.get("HTTP_X_ORG")) + middleware = ClicktagMiddleware(hello, api_key=KEY, consumer=lambda env: env.get("HTTP_X_ORG")) assert call(middleware, environ(HTTP_X_ORG="acme")) == (["201 Created"], b"hello") middleware.flush() @@ -61,7 +61,7 @@ def app(env: dict, start_response: Callable[..., Any]) -> list[bytes]: start_response("200 OK", []) return body - assert TotallyticsMiddleware(app, api_key=KEY)(environ(), lambda *args: None) is body + assert ClicktagMiddleware(app, api_key=KEY)(environ(), lambda *args: None) is body def test_records_lazy_responses_and_closes_them(ingest: Ingest) -> None: @@ -78,7 +78,7 @@ def __iter__(self) -> Any: def close(self) -> None: closed.append(True) - middleware = TotallyticsMiddleware(lambda env, start_response: Body(start_response), api_key=KEY) + middleware = ClicktagMiddleware(lambda env, start_response: Body(start_response), api_key=KEY) assert call(middleware, environ()) == (["404 Not Found"], b"missing") middleware.flush() assert closed == [True] @@ -89,7 +89,7 @@ def test_records_exceptions_as_500_and_reraises(ingest: Ingest) -> None: def app(env: dict, start_response: Callable[..., Any]) -> list[bytes]: raise KeyError("missing") - middleware = TotallyticsMiddleware(app, api_key=KEY) + middleware = ClicktagMiddleware(app, api_key=KEY) with pytest.raises(KeyError): call(middleware, environ()) middleware.flush() @@ -99,7 +99,7 @@ def app(env: dict, start_response: Callable[..., Any]) -> list[bytes]: def test_includes_the_script_name_and_decodes_paths(ingest: Ingest) -> None: - middleware = TotallyticsMiddleware(hello, api_key=KEY) + middleware = ClicktagMiddleware(hello, api_key=KEY) call(middleware, environ("/caf\xc3\xa9", SCRIPT_NAME="/api")) middleware.flush() assert metrics_of(ingest)[0]["route"] == "/api/café" From c4196270f66ea6c8c6dca2a9dbe8d78159600924 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sun, 11 Oct 2026 08:37:37 +0000 Subject: [PATCH 2/2] KEVIN.md: rename notes --- KEVIN.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/KEVIN.md b/KEVIN.md index 7955533..82b974f 100644 --- a/KEVIN.md +++ b/KEVIN.md @@ -1,11 +1,11 @@ # KEVIN.md -Status: v0.1.0 tagged at ac8b59b, CI green 3.9-3.14 (114 tests per leg), not on PyPI until the trusted publisher is added. +Status: renamed to `clicktag` (PR #1, 2026-10-11): dist + import `clicktag`, `Clicktag`, `ClicktagMiddleware`, `CLICKTAG_API_KEY` (no `TOTALLYTICS_API_KEY` fallback, never published). Not on PyPI yet. The v0.1.0 tag (ac8b59b) is pre-rename code: after the merge, delete it and re-tag v0.1.0 on the merge commit. Never re-run release run 36373965701, it builds the old `totallytics` dist. -Python port of `bitgate/totallytics-js` (the reference; its `WIRE.md` is the wire contract). Server: private `bitgate/totallytics`, `src/api-analytics/ingest.ts` (`normalizeBatch`). Never push to either from here. +Python port of `clicktag-io/clicktag-js` (was `bitgate/totallytics-js`) (the reference; its `WIRE.md` is the wire contract). Server: private `bitgate/totallytics`, `src/api-analytics/ingest.ts` (`normalizeBatch`). Never push to either from here. ## Layout -- `src/totallytics/`: `_client.py` (Totallytics, module `flush()`, atexit, fork reset), `_aggregator.py`, `_histogram.py`, `_transport.py` (urllib, retry/413/401/pending cap), `_util.py`, `_runtime.py` (logger `totallytics`, warn_once), `_middleware.py` (shared adapter options/callbacks) +- `src/clicktag/`: `_client.py` (Clicktag, module `flush()`, atexit, fork reset), `_aggregator.py`, `_histogram.py`, `_transport.py` (urllib, retry/413/401/pending cap), `_util.py`, `_runtime.py` (logger `clicktag`, warn_once), `_middleware.py` (shared adapter options/callbacks) - Adapters: `asgi.py` (generic, lifespan flush, route from `scope["route"]`), `starlette.py`/`fastapi.py` (Request for callbacks), `wsgi.py`, `flask.py` (wraps `app.wsgi_app` + `teardown_request`) - `tests/fixtures/conformance.json`: generated by `scripts/generate_fixtures.mts` from totallytics-js v0.1.0 (core unchanged through 0.1.1) @@ -20,14 +20,14 @@ Python port of `bitgate/totallytics-js` (the reference; its `WIRE.md` is the wir - Server now maps param-less 404 routes to `/*` itself. ## Verification recipes -- Conformance regen: `npx tsx scripts/generate_fixtures.mts > tests/fixtures/conformance.json` (needs `tsx` only, no npm ci in the JS repo). +- Conformance regen: `npx tsx scripts/generate_fixtures.mts > tests/fixtures/conformance.json` (needs `tsx` only, no npm ci in the JS repo). - Server check (not in CI, repo is private): clone `bitgate/totallytics`, `npm ci --ignore-scripts`, then `PYTHONPATH=. python scripts/server_payloads.py > /tmp/p.json && npx tsx scripts/validate_server.mts /tmp/p.json`. Last run 2026-09-28: 13 batches, 5198 metrics + 205 errors, 0 rejected. - Prod smoke: batch with key `tt_` + 48 zeros -> 401 `invalid or revoked API key`, no writes. ## Release -- Bump `src/totallytics/_version.py`, push tag `vX.Y.Z` -> `release.yml` builds + publishes via PyPI Trusted Publishing (environment `pypi`). -- PyPI trusted publisher must exist (owner `bitgate`, repo `totallytics-python`, workflow `release.yml`, env `pypi`). -- v0.1.0 Release run 36373965701 failed at publish with `invalid-publisher` (expected, no publisher yet). Once it exists: "Re-run all jobs" on that run. GitHub env `pypi` was auto-created, no protection rules. +- Bump `src/clicktag/_version.py`, push tag `vX.Y.Z` -> `release.yml` builds + publishes via PyPI Trusted Publishing (environment `pypi`). +- PyPI trusted publisher must exist (project `clicktag`, owner `clicktag-io`, repo `clicktag-python`, workflow `release.yml`, env `pypi`). +- v0.1.0 Release run 36373965701 failed at publish with `invalid-publisher` (expected, no publisher yet). Once the publisher exists, push a fresh tag, don't re-run it. GitHub env `pypi` was auto-created, no protection rules. ## Access - Classic PAT is rejected by the bitgate org; use the fine-grained PAT (also for `PATCH /repos` visibility).