Metadata-Version: 2.4
Name: traceten
Version: 1.0.0
Summary: Traceten server-side SDK — send AI-traffic and revenue events from your backend.
Author: Traceten
License: MIT
Project-URL: Homepage, https://traceten.com
Project-URL: Documentation, https://docs.traceten.com/sdks/python
Project-URL: Repository, https://github.com/traceten/sdk-python
Project-URL: Issues, https://github.com/traceten/sdk-python/issues
Keywords: traceten,traceten.com,analytics,attribution,ai-traffic,ai-analytics,web-analytics,product-analytics,revenue-attribution,conversion-tracking,funnels,ai-visibility,chatgpt,perplexity,llm-traffic,generative-engine-optimization,aeo,python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: ruff==0.14.5; extra == "dev"
Requires-Dist: black==25.11.0; extra == "dev"
Dynamic: license-file

<img src="https://traceten.com/logos/traceten-wordmark-black.png" alt="Traceten" width="320" />

# traceten (Python)

[![PyPI version](https://img.shields.io/pypi/v/traceten.svg)](https://pypi.org/project/traceten/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/python-3.9%2B-brightgreen)](https://www.python.org)

Server-side SDK for [Traceten](https://traceten.com). Send AI-traffic and
revenue events to Traceten from your backend, over authenticated HTTP that
ad-blockers and privacy browsers cannot strip.

Zero runtime dependencies (standard library only). Python 3.9+.

## Install

```bash
pip install traceten
```

## Quickstart

```python
import os

import traceten

client = traceten.Client(
    "ttid_7Rb4TrC1dTbnD8w3s1TS12",  # your site key, from the dashboard's install page
    "https://ingest.traceten.com",
    # Required. A secret: load it from your environment, never hardcode it.
    api_key=os.environ["TRACETEN_API_KEY"],
)

# A pageview / traffic event -> POST /v1/server/events
client.page(
    url="https://shop.example.com/pricing",
    visitor_id="123e4567-e89b-42d3-a456-426614174000",
    referrer="https://chatgpt.com/",
)

# A revenue / custom event -> POST /v1/server/conversions
client.track(
    "subscription_started",
    visitor_id="123e4567-e89b-42d3-a456-426614174000",
    value_cents=4900,   # minor units (cents), never dollars
    currency="usd",
)

# A goal completion -> the same endpoint, under the name the goal is counted by
client.goal("demo_booked", visitor_id="123e4567-e89b-42d3-a456-426614174000",
            properties={"plan": "pro"})

client.close()  # flushes anything still queued and stops the background thread
```

`page()`, `track()` and `goal()` return immediately. Events are buffered and delivered in
the background with batching and retry. Call `flush()` to force delivery now, or
`close()` on shutdown to drain the queues.

### `goal(name, *, visitor_id, ...)`

Same arguments and same endpoint as `track()`, with one difference: the reserved
names below raise `TracetenError`. A goal IS a custom event, so
`goal("demo_booked", ...)` and `track("demo_booked", ...)` send exactly the same
payload. Use `goal()` for something you want to count and put in a funnel, and
`track()` when you are recording revenue.

**Reserved names.** These belong to the Stripe and Shopify integrations, which
emit them for real subscription and payment events, so a goal may not use one:

`payment`, `free_trial`, `trial_started`, `trial_converted`,
`subscription_started`, `subscription_upgraded`, `subscription_downgraded`,
`subscription_renewed`, `subscription_cancel_scheduled`,
`subscription_reactivated`, `subscription_ended`.

`track()` still accepts them, because that is how those events are legitimately
sent.

**Whitespace is not trimmed.** `goal(" signup ")` throws. The browser snippet
trims a name read from an HTML attribute, because attribute values pick up
whitespace from how the page is formatted; a name written in server code does
not, so a stray space is a bug worth surfacing rather than quietly fixing.

**Property keys and values are both stored, and both are readable back.**
`GET /v1/goals/{name}/properties` returns every property key sent with a goal
and that key's most common values. Do not put an email address, a person's name,
or a postal address in either half of a property.

Ingestion drops a property whose key is exactly `email`, `phone`, `name`,
`password`, `token`, `ssn`, `credit_card` or `card_number`, and redacts email,
phone, card and national-ID patterns inside string values. It has no pattern
for a personal name or a street address, and it does not scan keys at all, so
`{"full_name": "Alice Chen"}` is stored and returned exactly as sent.

`page()`, `track()` and `goal()` return immediately. Events are buffered and delivered in
the background with batching and retry. Call `flush()` to force delivery now, or
`close()` on shutdown to drain the queues.

### `payment(*, transaction_id, amount, currency, ...)`

Records a payment from ANY payment processor (`POST /v1/server/payments`). The
only method here that blocks and that raises on a delivery failure: a dropped
pageview is a dropped pageview, a dropped payment is missing revenue.

```python
result = client.payment(
    transaction_id="pay_9fK2mQ",  # required — the processor's id. Idempotency key.
    amount=49.99,                 # required — MAJOR unit, not cents
    currency="USD",               # required — ISO-4217, sent uppercase
    provider="dodo",              # optional — your label. Defaults to "api".
    email="ada@example.com",      # optional — hashed server-side, never stored
    visitor_id=vid,               # optional — a stronger match than the email
    renewal=False,                # optional
    refunded=False,               # optional — never send a negative amount
    is_free_trial=False,          # optional — implied by amount=0
    settlement_amount=48.50,      # optional — provider's own conversion, fallback only
    settlement_currency="USD",    # optional — must be set together with settlement_amount
)

result["status"]  # "recorded" | "trial" | "refunded" | "duplicate"
```

`amount` is the MAJOR unit, the opposite of `track()`'s `value_cents`: `49.99`
for $49.99, `5000` for ¥5000.

`settlement_amount`/`settlement_currency` are a fallback for when `currency` is
not one Traceten can price on its own: the processor's own conversion of the
payment into a currency it always settles in (e.g. Dodo always settles in
USD/GBP/EUR). Send them only together — setting just one omits both from the
request rather than sending a partial pair.

Re-posting the same `transaction_id` returns `"duplicate"` and creates nothing,
which is why a 5xx is retried here. It raises `TracetenError` on an invalid
field and `DeliveryError` after every retry is exhausted.

⚠️ Do NOT send payments here for a processor you have also connected natively.
Traceten would record the payment twice and overstate your revenue.

## The API key

`api_key` is required. Create a key in the dashboard under **Settings -> API
keys**, or use the key shown once when you created the site.

Keep it on your server. It is a secret: never put it in client-side code, a
mobile app, or a public repository. It is not the same value as the site id,
which is public and already embedded in your pages.

The key does two things:

- **Gets you in.** The SDK posts to the authenticated ingestion endpoints, which
  return `401` without a valid key.
- **Gets you your own quota.** Authenticated traffic is rate-limited on a bucket
  tied to the key, separate from the shared per-site bucket. The site id is
  public, so anyone who can read your page source can send events under it. With
  a key, that traffic cannot exhaust your allowance and 429 your conversion
  calls.

The constructor validates the key's shape and raises `TracetenError` if it is
malformed. Omitting it entirely is a `TypeError`: `api_key` is a required
keyword argument, so Python refuses the call.

### Permissions

A key carries a set of permissions that decide which endpoints it can reach.
This SDK sends to `/v1/server/*`, which requires **`ingest:write`**. Tick that
permission when you create the key.

The constructor cannot check this for you. Permissions live on the server and
the key looks identical either way, so a key without `ingest:write` is rejected
with the same `401` as an invalid one.

Grant only what you need. A key used solely for server-side ingestion does not
need permission to read your analytics or erase visitor data, and if it leaks it
cannot do either.

To rotate a key: create the new one, deploy it, then revoke the old one.
Revocation normally takes effect within about a minute. If our database is
unreachable at that moment, an edge location that was already using the key may
keep honouring it for up to about fifteen minutes more, so that a database blip
cannot silently drop your events.

## Identifiers

Server-side there is no cookie and no DOM, so the SDK never fabricates a
visitor. You supply `visitor_id` (and optionally `session_id`) from your own
request context. A `visitor_id` is a UUID or the identify-hash form
`h:<64 hex chars>`. `track()` requires one; `page()` does not.

The robust way to get this value is `window.traceten.getVisitorId()`, called
client-side and forwarded to your backend (a form field, a fetch body, a
header) — it always resolves the current cookie, so it keeps working if a
customer turns cross-subdomain cookies on or off later. If you read the
cookie by name instead, its name depends on the site's cookie scope:
cross-subdomain cookies are off by default, giving plain `_traceten_vid`;
once a customer enables it, the cookie becomes `_traceten_vid_` followed by
eight characters of the site key. The install page shows the exact current
name. Read that name exactly, never by prefix: two Traceten sites under one
registered domain each set their own cookie, and a prefix match picks
whichever the browser happens to list first, which merges two visitors the
suffix exists to keep apart. If a request carries no such value, send the
event without a `visitor_id` rather than inventing one.

## User agent

The same reasoning applies to `user_agent`. Pass the end user's User-Agent if
you know it — that is what gets recorded as the visitor's user agent. Omit it
and the event falls back to the `User-Agent` this SDK's HTTP client sent,
`traceten-python/<version>`, which describes your server, not the visitor.
User agent is an input to Traceten's traffic classification, so passing the
real one materially improves your results.

## Full documentation

See [`API.md`](./API.md) for the complete API
reference, configuration options, retry semantics, and error handling.

## Development

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
ruff check . && black --check . && pytest
```

## Versioning

This package follows [Semantic Versioning](https://semver.org/). Before `1.0.0`,
minor versions may include breaking changes — pin an exact version in production
until then. See [CHANGELOG.md](./CHANGELOG.md) for release history.

## Contributing

Issues and pull requests are welcome. For anything beyond a small fix, please
open an issue first to discuss the change. Run the checks in Development above
before submitting a PR — CI enforces the same steps on every pull request.

## License

MIT © Traceten — see [LICENSE](./LICENSE).

## Links

- [Documentation](https://docs.traceten.com/sdks/python)
- [Traceten](https://traceten.com) — AI traffic attribution for the AI search era
- [Issues](https://github.com/traceten/sdk-python/issues)
- [Changelog](./CHANGELOG.md)
- [Node SDK](https://github.com/traceten/sdk-node) · [Go SDK](https://github.com/traceten/traceten-go) · [AI crawler tracking](https://github.com/traceten/ai-crawl)
