Skip to content

Support public ingestion keys in Python logging - #845

Closed
Luca Forstner (lforst) wants to merge 5 commits into
mainfrom
lforst/dum-e/mexico-city-869a592e19
Closed

Luca Forstner (lforst) wants to merge 5 commits into
mainfrom
lforst/dum-e/mexico-city-869a592e19

Conversation

@lforst

@lforst Luca Forstner (lforst) commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Adds init_logger(ingestion_key=...) and BRAINTRUST_INGESTION_KEY, so code running on machines we don't control (desktop apps, CLIs shipped to users) can send traces to one project without an API key.

This is part of the public trace ingestion keys work and depends on a backend that serves the ingestion key routes. Don't release it until a compatible data plane and key issuance are deployed.

The wire contract lives in braintrustdata/braintrust#21591. I checked this PR against its shared fixture (typespecs/src/public-ingestion.fixture.json at 74ce022): all valid and invalid URLs, the derived endpoints, the chunk plans, and the upload request, grant, chunk, complete, and overflow shapes match. The fixture isn't vendored here; the tests use an inline fake data plane that follows the same contract. End-to-end coverage against the real server is planned separately.

What changes

The ingestion key is a URL like https://dp.example/base/ingest?ingestKey=bt-ik-.... The SDK parses it locally, strips the query from every request, and sends the key as Authorization: Bearer .... The base path is preserved, so rows go to {root}/v1/logs.

An ingestion key logger gets its own background logger per endpoint and key, isolated from the global private one. It never logs in, never registers or looks up the project, and never falls back to BRAINTRUST_API_KEY, an earlier braintrust.login(), or the private URL, including when a write is rejected. Precedence:

  • explicit api_key and ingestion_key together raise
  • explicit api_key uses the private path, even if BRAINTRUST_INGESTION_KEY is set
  • otherwise an explicit ingestion_key (including an empty or malformed one, which raises) or the env var selects the public path

Rows only carry trace fields (id, span ids, created, project_id/log_id, input/output/…, merge fields). If the logger has no project_id, rows only carry log_id: "g" and the data plane resolves the project from the key. An explicit project_id is sent as-is, so the server rejects mismatches instead of the SDK hiding them. Feedback can only include scores, expected, and tags, since comments and audit fields are writable provenance.

Attachments upload through the chunked /v1/uploads API before the rows that reference them, using the chunk size the server grants. The returned server reference replaces the attachment in the row. ExternalAttachment is rejected, since its URL can point at any object store content. Batches above 512 KiB overflow through the same API as a logs3_overflow upload, and there's no /version lookup. Upload grants are validated, timeouts and retry waits never outlive the grant, and an expired or 410 grant starts a new upload a bounded number of times.

Requests don't follow redirects, retries only cover 408/429/5xx and network errors (honoring Retry-After up to 60s), and the key is redacted from errors and never part of a URL or repr.

Slug and W3C parents, @traced, and global update_span() route through the current ingestion key logger, so they don't trigger a private login. Public and private spans can still share a trace in both directions.

The README documents that anyone holding the key can update any project row whose ID they know, including rows written with an API key or another ingestion key, and how to point a standard OTLP exporter at {root}/otel/v1/traces.

Verification

  • pytest src/braintrust/test_ingestion_key.py: 69 passed. These run the real logger, batcher, and upload flow against a fake data plane over HTTP.
  • nox -s test_core: 1013 passed, 4 failed. The same 4 fail on main in the sandbox I ran this in, because its HTTP_PROXY turns the connection-close scenarios into 502s (api/test_transport.py ×2, test_git_metadata_vcr.py, test_http.py).
  • pre-commit (ruff format, ruff check, codespell) passes on the changed files, and pylint --errors-only on the changed files passes. The full nox -s pylint session didn't run because the tool request was rejected.
  • GitHub Actions on 27662d0: all 82 checks pass across Linux and Windows (1 skipped).

`init_logger(ingestion_key=...)` and `BRAINTRUST_INGESTION_KEY` create a logger that writes traces to a single project with an ingestion key URL.
The logger has its own queue and transport per endpoint and key, sends the key as a bearer token, and never logs in, discovers the project, or falls back to private credentials.
An explicit `api_key` takes precedence over the env var, and passing both options is an error.

Attachments and oversized batches go through the data plane's chunked `/v1/uploads` API before the rows that reference them are published.
Rows only carry trace fields, and feedback is limited to scores, expected values, and tags, since comments and audit fields are writable provenance.
Slug and W3C parents, plus global `update_span`, route through the current ingestion key logger so they don't trigger a private login.
The data plane rejects `external_attachment` references from ingestion keys, since they can point at arbitrary object store content.
Rows containing an `ExternalAttachment` are now dropped with a clear error instead of being published.

Batches above 512 KiB now overflow through an upload, since Lambda base64-encodes binary bodies and ingress accepts 1 MiB by default.
An explicit `ingestion_key=""` or malformed URL now raises instead of falling back to `BRAINTRUST_INGESTION_KEY` or the private login.

Upload grants with malformed lifetimes, chunk counts, or ids are rejected, a 410 on a chunk or commit starts a new upload, and request timeouts and retry waits never outlive the grant.
Requests don't follow redirects, so the key can't be forwarded elsewhere, and transport errors are redacted.

The README now says that anyone holding a key can update any project row whose ID they know.
Empty `&` segments are now skipped instead of rejecting the URL, which matches how the other SDKs and the data plane read the query.
Windows has a coarser monotonic clock and words refused connections differently, so expire the grant with a delayed response and match the urllib3 error text instead.
The closed-port check now relies on the SDK's retry message instead of urllib3 wording, and the grant expiry test pins the exact request sequence so nothing can be sent against the expired grant.
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.

1 participant