Skip to content

feat(clients): identify the SDK with a User-Agent header - #1561

Merged
erni-a merged 4 commits into
mainfrom
feat/clients-user-agent
Sep 19, 2026
Merged

erni-a merged 4 commits into
mainfrom
feat/clients-user-agent

Conversation

@erni-a

@erni-a erni-a commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What and why

Both caura-client packages now send a User-Agent header naming the package, its version and the runtime. A self-hosted Caura server can then count which SDK families talk to it, which is what the clients_24h buckets of the Caura Heartbeat v1 spec (client PRs C1/C2) need. Nothing else is added to any request and no request goes anywhere new: the clients still talk only to the base_url / baseUrl you configure. The header is the whole change.

Headers

  • Python: caura-client-python/<version> (python/<major>.<minor>), e.g. caura-client-python/1.0.2 (python/3.12)
  • Node: caura-client-node/<version> (node/<major>), e.g. caura-client-node/1.0.1 (node/20); in a browser bundle the Node tag is omitted (and fetch drops the header anyway, which is fine)

Changes

  • clients/python: version literal moves to caura_client/_version.py so client.py can import it without a cycle; __init__ still exports __version__ (the caura / caura-sdk meta packages import it). USER_AGENT constant added to the httpx.Client default headers. Tests assert the header on every request and that __version__ agrees with pyproject.toml.
  • clients/typescript: new src/version.ts (no version constant existed in source), re-exported as VERSION; USER_AGENT constant added to the request headers with process.versions?.node guarded through globalThis. dist/version.js / dist/version.d.ts added to the published files. Tests assert the header and that VERSION equals package.json's version.
  • READMEs of both clients gain a "Request headers" section listing exactly what is sent.
  • No version bumps; releases are cut by the tag-driven publish workflows as usual.

Testing

Run exactly as client-python-ci.yml and client-typescript-ci.yml do:

  • clients/python: pip install -e ".[dev]", ruff check src tests (all checks passed), pytest -q (148 passed)
  • clients/typescript: npm install, npm test (tsc + node --test, 20 passed); npm pack --dry-run confirms dist/version.js and dist/version.d.ts ship in the tarball

@erni-a
erni-a requested a review from a team as a code owner September 17, 2026 16:54
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

@erni-a
erni-a force-pushed the feat/clients-user-agent branch from d497d4e to e479677 Compare September 18, 2026 06:07
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

erni-a added a commit that referenced this pull request Sep 19, 2026
…e it (#1587)

## Why

AI answer engines cite pypi.org 506 times in our GEO benchmark (27 May
to 11 Sep 2026); Mem0 has 45 attributed PyPI pages, Caura has 0. Engines
read only the release metadata and the rendered long description.
Checked against the PyPI JSON API on 18 Sep: `caura` and `caura-sdk`
have a 4-line description, no keywords and no classifiers;
`caura-client` opens with a retirement blockquote and leaked `<!--
legacy-name-… -->` markers and never says what Caura is beyond one line.

## What changes

Every Caura-owned PyPI page now opens with the same header block (what
Caura is, the former name, MCP/REST/Rail access, tenant isolation,
visibility scope and trust level), and all three packages share one
summary style, keyword set, classifier set and project-URL set
(Homepage, Documentation, Source, Issues, Changelog, Benchmark →
LongMemEval harness).

- **`caura`** becomes the product page: install, a 60-second
write/search/recall quickstart, the MCP client config block, the three
ways agents use memory (Rail, MCP/REST, Interviewer), links. Under a
screen and a half.
- **`caura-sdk`**: same page with its own install line; import package
stays `caura_sdk`.
- **`caura-client`**: header block, one-line rename notice, then the API
README. Strict-writes (422) notes move below the API sections; Benchmark
and "what Caura is" links go above the API table.
- **Publish workflows** strip the legacy-name lint markers from
`README.md` before `python -m build` and fail if any HTML comment
survives. The source keeps the markers so the rename ratchet still
accounts for each mention; the published long description no longer
carries them.

`memclaw-client` is intentionally untouched: 0.5.0 is yanked and its
source was removed in #1244.

## Not in this PR

No version bumps. Metadata only reaches PyPI on a release, so once this
is merged a follow-up bump (`caura` 1.0.2, `caura-sdk` 1.0.1,
`caura-client` 1.0.3) plus the `caura-meta-v*` / `caura-sdk-v*` /
`caura-client-v*` tags ship it. Merging #1561 first keeps the client
version file and README in one place.

## Verification

- `python -m build` + `twine check dist/*` + `python -m readme_renderer
README.md` on all three packages with markers stripped: PASSED, built
METADATA has 0 HTML comments, first description line is the header
block.
- `scripts/legacy_name_ratchet.py --base origin/main`: no new lines (2
exempt lines removed, 4 floor mentions and 3 aliases listed).
- `tests/test_client_metapackages.py`: 18 passed.
- `clients/python`: `ruff check src tests` clean, `pytest` 150 passed.

Done-when check after release, from any shell:

```
curl -s https://pypi.org/pypi/caura/json | python -c "import json,sys; i=json.load(sys.stdin)['info']; print(i['summary']); print(i['keywords']); print(i['project_urls']); print(i['description'][:400])"
```

Signed-off-by: erni <erni@caura.ai>
erni-a added a commit that referenced this pull request Sep 19, 2026
…1619)

Version bumps only, so the PyPI page work from #1587 reaches the index.
Metadata changes only reach PyPI on a new release.

| Package | Directory | Version | Tag to push after merge |
|---|---|---|---|
| `caura` | `clients/caura-meta` | 1.0.1 → 1.0.2 | `caura-meta-v1.0.2` |
| `caura-sdk` | `clients/caura-sdk-meta` | 1.0.0 → 1.0.1 |
`caura-sdk-v1.0.1` |
| `caura-client` | `clients/python` | 1.0.2 → 1.0.3 |
`caura-client-v1.0.3` |

The `caura-client` bump also updates `__version__` in
`caura_client/__init__.py`. If #1561 (SDK User-Agent header) merges
first, this PR needs a one-line rebase to set the version in the
`_version.py` it introduces instead, and 1.0.3 then ships the User-Agent
header too. If this merges first, #1561 ships in a later 1.0.4.

Each publish workflow checks the tag against `pyproject.toml` and then
confirms PyPI serves the version.

Signed-off-by: erni <erni@caura.ai>
@erni-a
erni-a force-pushed the feat/clients-user-agent branch from e479677 to b2e0af8 Compare September 19, 2026 14:21
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

Eldad-Caura
Eldad-Caura previously approved these changes Sep 19, 2026
Both caura-client packages now send a User-Agent naming the package, its
version and the runtime, so a self-hosted Caura server can count which SDK
families talk to it (the heartbeat's clients_24h buckets). Nothing else is
added to any request and no request goes anywhere new; the header is the
whole change.

  caura-client-python/<version> (python/<major>.<minor>)
  caura-client-node/<version> (node/<major>)

The Python version literal moves to caura_client/_version.py so client.py
can import it without a cycle; __init__ still exports __version__ for the
meta packages. The TypeScript client gains src/version.ts (no version
constant existed in source) plus a test that it agrees with package.json,
and dist/version.* joins the published files. Node detection is guarded so
a browser bundle does not crash. READMEs document the headers each client
sends.

Signed-off-by: erni <erni@caura.ai>
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org

@erni-a
erni-a merged commit 42e537b into main Sep 19, 2026
16 checks passed
@erni-a
erni-a deleted the feat/clients-user-agent branch September 19, 2026 15:16
@caura-deploy-bot caura-deploy-bot Bot mentioned this pull request Sep 19, 2026
erni-a pushed a commit that referenced this pull request Sep 19, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>backend: 3.17.0</summary>

##
[3.17.0](backend-v3.16.3...backend-v3.17.0)
(2026-09-19)


### Features

* **clients:** identify the SDK with a User-Agent header
([#1561](#1561))
([42e537b](42e537b))
* **core-api:** send an anonymous daily heartbeat from self-hosted
servers ([#1577](#1577))
([35ef4ca](35ef4ca))
* **plugin:** identify the plugin with a User-Agent header
([#1578](#1578))
([3cdb2f7](3cdb2f7))


### Bug Fixes

* **clients:** wrap network failures in the SDK error hierarchy
([#1588](#1588))
([99ee1ea](99ee1ea))
* **events:** make a failed publish visible to the platform, not just
the SDK ([#1617](#1617))
([f26e45d](f26e45d))
* **storage:** name the phase when cross-link discovery times out
([#1620](#1620))
([7d3fbcb](7d3fbcb))


### Dependencies

* update alembic requirement from &lt;2,&gt;=1.19.1 to &gt;=1.20.0,&lt;2
([#1569](#1569))
([3f07940](3f07940))
* update google-cloud-aiplatform requirement from &lt;3,&gt;=2.0.1 to
&gt;=2.1.0,&lt;3
([#1570](#1570))
([132981b](132981b))


### Documentation

* **clients:** make the PyPI pages answer what Caura is and how to use
it ([#1587](#1587))
([f5e4c5c](f5e4c5c))


### Code Refactoring

* **storage:** drop three zero-caller service methods, and gate the
topic registry ([#1618](#1618))
([1c206e0](1c206e0))
</details>

<details><summary>plugin: 2.23.0</summary>

##
[2.23.0](plugin-v2.22.3...plugin-v2.23.0)
(2026-09-19)


### Features

* **plugin:** identify the plugin with a User-Agent header
([#1578](#1578))
([3cdb2f7](3cdb2f7))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
erni-a added a commit that referenced this pull request Sep 19, 2026
…ly on a bad collector URL (#1623)

## Summary

End-to-end validation of the heartbeat shipped in #1577 (backend 3.17.0)
found that a Docker container sends two beats per cycle, that a mistyped
collector URL is announced as ON and then refused silently, and a
handful of smaller reporting and documentation defects. This PR fixes
all of them in one place, and bumps the TypeScript client so the
User-Agent header from #1561 can be published.

## Defects and fixes

**D1 - two heartbeats per container.** `core-api/Dockerfile` runs
uvicorn with `--workers 2`; every worker runs the lifespan, so each
started its own loop. Observed against production on 2026-09-19:
deployment `0e9d4f29...` POSTed at 16:23:52Z and 16:23:59Z, deployment
`1f866780...` at 16:28:55Z and 16:29:21Z (both 202; the receiver's 20 h
dedupe hid the second). `GET /api/v1/telemetry` answered differently
depending on which worker took the request (`last_sent_at`,
`next_send_at`, `payload_preview`), and the in-process `clients_24h`
counter was split, so the stored beat carried only the sending worker's
half (one worker previewed `caura-client-python "21-100"`, the other
`"0"`).

Fix (no new dependency, worker count unchanged): a per-container state
directory (`CAURA_TELEMETRY_STATE_DIR`, default
`<tmpdir>/caura-heartbeat`, created 0700) and a new
`core_api.heartbeat.state` module.
- Leader election: a non-blocking `fcntl.flock` on `<dir>/leader.lock`
taken in `install()`. The holder runs the send loop; the others run no
loop and print a "follower" line instead of a second ON line. Followers
retry the lock every 60 s and take over if the leader dies (the kernel
drops the lock), resuming from the leader's published `next_send_at`
(bounded by one interval).
- Shared client counter: every worker flushes its in-memory family
counter every 30 s and on shutdown to `<dir>/clients-<pid>.json`
(write-to-temp + atomic rename). At send time the leader sums its live
counter with every other live worker's file; on 202 it zeroes its own
counter and writes `<dir>/epoch`; a worker seeing a newer epoch at its
next flush subtracts what it had already flushed. Files of dead pids are
pruned (`os.kill(pid, 0)`).
- Shared status: the leader writes `<dir>/state.json` after each cycle
step; a follower answers `GET /api/v1/telemetry` from it, so every
worker reports the same values. `payload_preview` is built by the
answering worker from the summed counters.
- Unwritable directory: WARNING at boot and fall back to today's
one-loop-per-worker behaviour; never a crash. Empty variable: same
fallback, by choice, at INFO.

**D2 - plain-http collector URL announced ON, refused silently.**
`check_endpoint_url` ran only in `send_once`, at DEBUG, so the boot line
said ON, `GET` said `enabled: true`, and nothing was ever sent or
explained. The check now lives in `policy.evaluate()`: an invalid URL is
`Disabled(reason="invalid_endpoint_url")`, the boot line prints OFF with
the reason and the offending URL at WARNING, and `GET` shows `enabled:
false`, the reason and the endpoint. The send-time check stays as
defence in depth.

**D5 / docs.** README said "`CI` set to any value"; code and
`docs/telemetry.md` say non-empty (an empty `CI=` keeps the heartbeat
on). README now says non-empty.

**Small fixes.**
- (a) Delivery failures (connection refused, timeout, non-202) left
`last_status`/`last_sent_at` null with no trace. The state and `GET`
gain `last_error` (short, URL-stripped, so a credential in an override
can never surface) and `last_attempt_at`; failures are logged once per
cycle at WARNING instead of DEBUG.
- (b) `CAURA_VERSION=v3.17.0`, the pinning form `docs/self-hosting.md`
recommends and which core-api reads from `.env`, was sent verbatim. The
payload `version` and the `User-Agent` now strip a leading `v`.
- (c) `docs/telemetry.md` now states: one beat per container regardless
of worker count (and how), source runs report `dev` unless
`CAURA_VERSION` is set (and that the collector excludes `dev`),
`providers.*` report the configured kind even when the server fell back
to the fake provider, the new `invalid_endpoint_url` row,
`last_error`/`last_attempt_at`, and the new boot lines. README kept
consistent. `.env.example` documents `CAURA_TELEMETRY_STATE_DIR`.

**TypeScript client 1.0.2.** The published `@caura/client` (1.0.1)
predates the User-Agent header from #1561, so its requests are counted
as `other`. `clients/typescript` is bumped to 1.0.2 (`package.json`,
`package-lock.json`, `src/version.ts`; the `VERSION agrees with
package.json` test passes). Publishing needs tag
`caura-client-ts-v1.0.2` on the merge commit (publish-npm-client.yml).

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Documentation update

## How Has This Been Tested?

New tests:
- `tests/test_heartbeat_state.py`: lock acquisition and hand-over
(including a real child process that dies holding the lock),
counter-file aggregation, epoch reset, stale/malformed/dead-pid files,
`state.json` round trip.
- `tests/test_heartbeat_multiworker.py`: two `multiprocessing` worker
processes sharing a state directory against a fake receiver; asserts
exactly one POST per cycle over two cycles, that the beat carries the
summed counts (`1 + 1 -> "2-5"`, then `"0"` after the epoch reset),
identical status from both workers, `v3.17.0 -> 3.17.0`, and that
shutdown releases the lock. Runs in about 5 s.
- Extended `test_heartbeat_sender.py` (roles, takeover, `last_error`,
WARNING once per cycle, URL-free error text), `test_heartbeat_policy.py`
(URL row, boot lines, `install()` role election and the unwritable-dir
fallback), `test_api_telemetry.py` (new fields, a follower worker's
`GET`, the bad-URL response), `test_heartbeat_payload.py` (version
normalisation).

Gates, run as in `ci.yml` from a fresh `uv venv -p 3.12` with all four
packages editable: `ruff check` + `ruff format --check` on
`core-api/src` and `tests/`; `cd core-api && mypy src/`;
`scripts/tenant_scope_gate.py --base origin/main`;
`scripts/legacy_name_ratchet.py --base origin/main` (no new lines);
`pytest tests/ -m "not benchmark"` against a `pgvector/pgvector:pg16`
container with CI's env after `alembic upgrade head`;
`clients/typescript`: `npm install` + `npm test` (33 pass).

Docker check: built `core-api/Dockerfile` from this branch and ran it
through `docker-compose.yml` (the real CMD, two uvicorn workers) with a
receiver sidecar sharing the container's network namespace on
`127.0.0.1:8010`. Boot log: one `anonymous heartbeat ON` line (worker
pid 10) and one `anonymous heartbeat follower` line (worker pid 9),
where 3.17.0 printed two ON lines. Six `GET /api/v1/telemetry` calls
spread over both workers returned the same `deployment_id` and
`next_send_at` (`2026-09-19T17:16:29Z`); the receiver saw exactly one
POST, at `17:16:29Z`, whose `clients_24h.caura-client-python` was `6-20`
(those six calls, summed across both workers). After the beat both
workers reported `last_sent_at 17:16:29Z`, `last_status 202`,
`last_error null` and the same `next_send_at`. The state directory held
`leader.lock`, `clients-9.json`, `clients-10.json`, `state.json` and,
after the send, `epoch`.

## Checklist

- [x] I have read CONTRIBUTING.md
- [x] I have added tests that cover my changes
- [x] `ruff check` and `ruff format --check` pass
- [x] `mypy` passes
- [x] `pytest` passes locally
- [x] I have updated relevant documentation (README, docs/telemetry.md,
.env.example)
- [ ] CHANGELOG.md is generated by release-please from the squash title

## Additional Notes

- Separate containers (replicas) still each send; the collector's 20 h
dedupe per deployment covers that case, as before. The lock is per
container because `/tmp` is.
- `docs/telemetry-schema-v1.json` is unchanged: no payload field was
added or renamed.

Signed-off-by: erni <erni@caura.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants