Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions KEVIN.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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 <totallytics-js checkout at tag> > tests/fixtures/conformance.json` (needs `tsx` only, no npm ci in the JS repo).
- Conformance regen: `npx tsx scripts/generate_fixtures.mts <clicktag-js checkout, 0.3.0+> > 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 <server checkout> /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).
Expand Down
44 changes: 22 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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+
Expand All @@ -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",
)
Expand All @@ -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)
```

Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down
10 changes: 5 additions & 5 deletions scripts/generate_fixtures.mts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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`)
12 changes: 6 additions & 6 deletions scripts/server_payloads.py
Original file line number Diff line number Diff line change
@@ -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 <totallytics server checkout> payloads.json
Then: npx tsx scripts/validate_server.mts <clicktag server checkout> payloads.json
"""

from __future__ import annotations
Expand All @@ -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] = []
Expand All @@ -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"]:
Expand All @@ -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],
Expand All @@ -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__":
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate_server.mts
Original file line number Diff line number Diff line change
@@ -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 <totallytics server checkout> payloads.json
// Usage: npx tsx scripts/validate_server.mts <clicktag server checkout> payloads.json
import fs from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
Expand Down
5 changes: 5 additions & 0 deletions src/clicktag/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
File renamed without changes.
10 changes: 5 additions & 5 deletions src/totallytics/_client.py → src/clicktag/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/totallytics/_runtime.py → src/clicktag/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
import threading

logger = logging.getLogger("totallytics")
logger = logging.getLogger("clicktag")

_warned: set[str] = set()
_warned_lock = threading.Lock()
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading