Metadata-Version: 2.4
Name: vesta-analytics
Version: 0.2.0
Summary: Vesta customer SDK — instrument an MCP server with one call.
Project-URL: Homepage, https://vesta-analytics.ai
Project-URL: Documentation, https://vesta-analytics.ai
Author-email: Vesta <hello@vesta-analytics.ai>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: analytics,mcp,model-context-protocol,observability,opentelemetry
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.12
Requires-Dist: openinference-instrumentation-mcp
Requires-Dist: opentelemetry-api>=1.27
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
Requires-Dist: opentelemetry-sdk>=1.27
Provides-Extra: fastmcp
Requires-Dist: fastmcp; extra == 'fastmcp'
Provides-Extra: mcp
Requires-Dist: mcp; extra == 'mcp'
Description-Content-Type: text/markdown

# vesta-analytics

Analytics for MCP servers. One call instruments your server and ships behavioural
data to Vesta over OTLP/HTTP — which tools agents actually call, what they pass,
where they fail, and who comes back.

```bash
pip install vesta-analytics
```

```python
import vesta
vesta.instrument(server, api_key="vsk_...")
```

Distribution name `vesta-analytics`; you import it as `vesta`.

## Two things to know before you install

**It cannot break your server.** Every capture path is fail-open: if Vesta's
exporter is down, misconfigured, or throws, your handler still runs and your
result is still returned.

**Redaction runs inside your process.** Arguments and responses pass through the
redaction pass *before* anything is exported. A field you mark `redact` never
crosses the network — Vesta cannot "un-redact" it later, because it was never
sent. Out of the box everything is redacted; you choose what to let through.

## 1. Get an API key

Vesta is in early access. Request access at
<https://vesta-analytics.ai/request-access>, then sign in and create a key. Keep
it secret.

## 2. Instrument your server

Add the call **after your tools are registered and before you serve**. The
adapter wraps the handlers that exist at the moment you call it, so a tool
registered afterwards is not captured.

```python
import vesta
from mcp.server.lowlevel import Server

server = Server("my-server")

@server.call_tool()
async def call_tool(name: str, arguments: dict): ...

vesta.instrument(server, api_key="vsk_...")   # <- after tools, before serve
```

Calling it twice on the same server is a no-op.

### Did it work?

```python
vesta.instrument(server, api_key="vsk_...", verbose=True)
```

`verbose=True` logs whether spans exported successfully — that tells you the data
left your process.

Then make one real tool call and open
<https://vesta-analytics.ai/app/settings>, where you can watch spans arrive.
Export is asynchronous, so give it a few seconds.

If `verbose` reports a successful export but nothing shows up, email
<info@datafenix.ai> with your surface name and roughly when you made the call.

## 3. What Vesta sees

**Always captured, whatever you configure:** the tool name, the MCP method,
timing, whether the call errored, and a session id. Redaction governs *argument
and response values* — nothing else.

Those values are your choice, field by field. The default is to redact every one
of them — safe, but it means Vesta can tell you a tool was called and how long it
took, and almost nothing else. Most of the value (which arguments matter, which
options nobody uses, where agents retry) needs values.

| Action | Vesta sees |
| --- | --- |
| `capture` | the real value |
| `hash` | a stable fingerprint of it — the same input always gives the same digest |
| `redact` | nothing |
| `detect` | the text, with anything matching an email/phone/card/IP pattern masked |
| `truncate(n)` | the first `n` characters |

### What to do with each kind of field

Find the fields your tools take, and do this:

| Your field | Do this | What it gives you |
| --- | --- | --- |
| `user_id`, `account_id`, `org_id` | `hash` | Distinct users, returning users, retention — without Vesta ever holding the id |
| `document_id`, `ticket_id`, `project_id` | `hash` | Which records agents hit hardest, without reading them |
| `mode`, `filter`, `sort`, `status`, `type` | `capture` | Which options agents actually use — and which nobody has ever touched |
| `limit`, `offset`, `page_size` | `capture` | Agents pulling 500 rows when 10 would do |
| `locale`, `currency`, `timezone` | `capture` | Where your traffic comes from |
| Search `query`, `question`, `prompt` | `detect` | What agents are actually looking for, with PII masked out |
| `title`, `description`, `body`, `comment` | `detect`, or `redact` if it's a message body | Enough to see what agents write, without the whole payload |
| File paths, resource URIs | `capture`, or `truncate(n)` if they embed ids | Which resources are hot |
| Person names — `assignee_name`, `author`, `customer_name` | `redact`, or `hash` to count them | **Nothing catches these for you — see below** |
| `email`, `phone`, `ssn`, card numbers, passwords, API keys | nothing to do | Already redacted automatically |
| Tool **responses** | `capture` or `detect` | Why calls fail: empty results, auth errors, dependency errors. Redact these and you lose the failure diagnosis |

Two things worth knowing, because they're free:

**Argument *names* are captured even when their values are redacted.** So agents
calling a tool with the wrong keys is detectable no matter how locked-down you
are. You don't have to capture a single value to get that.

**Redacting responses is the expensive one.** Whether a call came back empty, hit
an auth error, or failed on a dependency is read out of the response *text*. Lock
that down and Vesta can still tell you a call failed, but not why.

### What the safety nets don't catch

The automatic redaction works on **field names**. `detect` works on **patterns** —
emails, phone numbers, Luhn-checked cards, IP addresses.

Neither one catches a **person's name**, or a **street address written in prose**.
There is no pattern to match, so both sail straight through:

```python
{"assignee_name": "Ada Lovelace",
 "note": "call Ada Lovelace at 12 Wilbury Way about a@b.com"}

# with default="capture", text="detect":
{"assignee_name": "Ada Lovelace",                            # <- captured
 "note": "call Ada Lovelace at 12 Wilbury Way about <EMAIL>"} # <- only the email masked
```

If your tools take names or addresses, redact or hash them yourself. Nothing else
will.

### A good starting point

Capture the structure, scan free text, hash the ids, redact the secrets:

```python
import vesta
from vesta import RedactionConfig, Field, Tool

vesta.instrument(
    server,
    api_key="vsk_...",
    args=RedactionConfig(
        default="capture",     # values are the point — start from yes
        text="detect",         # ...but mask PII inside free-text strings
        rules=[
            Field("user_id").hash(),      # count distinct users, never read one
            Field("account_id").hash(),
            Tool("billing_*").redact(),   # everything else in billing_* calls
        ],
    ),
    responses=RedactionConfig(default="capture", text="detect"),
)
```

Three things that config is doing:

- **`default="capture"`** — values go through unless a rule says otherwise.
- **`text="detect"`** — but string values are scanned for PII first, and any
  match is masked. Without it, strings are captured as they are.
- **`responses=`** — set it, or your responses stay fully redacted. `args` and
  `responses` are configured **separately**, and each defaults to redact-all.

Switching `default` to `capture` is less reckless than it sounds. Whenever
`default="capture"`, these field names are still redacted automatically, at any
depth: `password`, `email`, `ssn`, `phone`, `address`, `credit_card`,
`card_number`, `cvv`, `dob`, `date_of_birth`, `tax_id`, `passport`, `api_key`,
`auth_token`, `bearer`, `secret`, `private_key`.

### Rules match a field name at any depth

MCP arguments are usually nested, so a bare name matches that field wherever it
appears — top level, inside `filters`, inside a list. Use a dotted path when you
mean one exact place:

```python
Field("assignee_id").hash()          # every assignee_id, at any depth
Field("filters.assignee_id").hash()  # only that one
Field("**.email").redact()           # explicit glob; same as the bare name
```

### When rules disagree

The most specific rule wins:

1. **`Field`** — and an exact path beats a bare name
2. **`Tool`** / **`Prompt`** / **`Resource`** — matching the thing being called
3. **`Method`** — e.g. `Method("resources/*")`
4. `text=`, then `default=`

So a field rule overrides a tool rule, and overrides the automatic denylist above.
In the config on this page, a `user_id` inside a `billing_*` call is **hashed, not
redacted** — the field rule is more specific than the tool rule. That's usually
what you want (you can still count users on billing calls), but if you need a tool
redacted with no exceptions, don't also write a field rule that captures or hashes
something inside it.

### What `hash` does and doesn't protect

`hash` is a stable fingerprint — the same input always produces the same digest,
so you can count distinct users, group by them, and follow cohorts over time.
It's keyed with a secret derived from your API key, which Vesta stores only as a
hash of itself. So Vesta cannot reverse your hashes from anything it stores, and
two customers hashing the same value get different digests.

But this is **pseudonymisation, not anonymisation**. A hashed identifier still
distinguishes one person from another, and hashed data is still personal data.
`hash` is for identifiers you want to count. Anything you'd rather Vesta never
held belongs in `redact`.

`detect` masks emails, phone numbers, Luhn-checked cards, IPs and national-id
patterns inside free text. It matches patterns, so it won't catch names or
addresses written in prose — a useful safety net, not a guarantee.

### Key rotation

Your hash key is derived from your API key, so rotating that key changes every
digest: the same user then looks like a new user. If you rotate keys, pass a
stable secret of your own instead, and Vesta never sees it.

```python
vesta.instrument(server, api_key="vsk_...", hash_salt=os.environ["VESTA_HASH_SALT"])
```

## 4. Tell Vesta who the user is

Distinct users, returning users and retention all come from a `user_id`.

**If your server authenticates with OAuth, you get this for free** — Vesta derives
a pseudonymous user id from the token's subject. Nothing to configure.

If it doesn't, supply one. The callable receives the framework's request object:

```python
vesta.instrument(
    server,
    api_key="vsk_...",
    session_context=lambda request: {"user_id": your_user_id(request)},
)
```

Whatever you return is **hashed inside your process** before it goes anywhere —
the same keyed hash described above — so Vesta never holds the raw value. It's
safe to return an email or an internal id here; neither reaches us.

## 5. Supported frameworks

- **Official `mcp` SDK** — the low-level `mcp.server.lowlevel.Server`, and the
  `FastMCP` bundled with it.
- **Community `fastmcp`** — the standalone package.

Detection is structural, so the MCP packages are optional extras — install only
the one you use:

```bash
pip install "vesta-analytics[mcp]"       # official SDK
pip install "vesta-analytics[fastmcp]"   # standalone fastmcp
```

Captured methods: `tools/call`, `prompts/get`, `resources/read`, `initialize`,
and the `*/list` methods.

## Shutdown / stdio delivery

Short stdio sessions export nothing until the process exits, so the shutdown
flush is the whole delivery path — keep your server alive a moment after a call
or you'll lose the batch. `instrument()` installs that flush and hard-bounds it
(2s, `VESTA_FLUSH_DEADLINE_MS` to override), so a dead collector can never hang
your process. The returned `InstrumentHandle` exposes `flush()` and `shutdown()`
if you'd rather drive the lifecycle yourself; ignoring it is fine.

## Reference

```python
vesta.instrument(
    server,                      # your MCP server object
    *,
    api_key: str,                # required
    endpoint: str = "https://ingest.vesta-analytics.ai/v1/traces",
    session_context: Callable[[Any], dict] | None = None,
    args: RedactionConfig | None = None,        # default: redact everything
    responses: RedactionConfig | None = None,   # default: redact everything
    transport: str | None = None,               # autodetected
    verbose: bool = False,
    hash_salt: str | None = None,               # default: derived from api_key
) -> InstrumentHandle | None
```

Environment variables: `VESTA_FLUSH_DEADLINE_MS` and `VESTA_TRANSPORT`. The API
key is passed to `instrument()`; there is no environment variable for it.

Requires Python 3.12+. Apache-2.0.
