Skip to main content
Applies to:
  • Plan -
  • Deployment -

Summary

Goal: Delete project logs programmatically and have them actually disappear from the UI and BTQL queries.

How deletion works

Deleting a log is done by re-inserting the event with _object_delete: true for each target row id:
delete_events = [{"id": row_id, "_object_delete": True} for row_id in ids]
requests.post(
    f"{API_URL}/v1/project_logs/{PROJECT_ID}/insert",
    headers=headers,
    json={"events": delete_events},
)
The _object_delete marker matches on the row id only. It does not match on span_id or root_span_id. Because the insert endpoint accepts any submitted id and echoes it back in row_ids, a 200 response means Braintrust recorded a delete tombstone for the IDs you sent, even when those IDs don’t correspond to any visible log row. That’s why a delete keyed on span_id or root_span_id “succeeds” but changes nothing. id and span_id are different values: id is the system-assigned unique row identifier, while span_id is an arbitrary value you can set and reuse across projects.

Solution: resolve row IDs first, then delete

Use BTQL to look up the actual row ids, then submit those ids as delete events.
import requests

API_URL = "https://api.braintrust.dev"  # Replace with your data plane API URL if self-hosted
PROJECT_ID = "<project id>"  # UUID, not the project name
trace_root_span_id = "<root span id of the trace to delete>"
headers = {"Authorization": "Bearer " + "sk-your-api-key"}

# Look up the row IDs for every span in the trace.
# Paginate if your traces have more than 100 spans.
btql_resp = requests.post(
    f"{API_URL}/btql",
    headers=headers,
    json={
        "query": (
            f"select id from project_logs('{PROJECT_ID}') "
            f"where root_span_id = '{trace_root_span_id}' limit 100"
        )
    },
)
span_ids = [row["id"] for row in btql_resp.json().get("data", [])]
print(f"Trace contains {len(span_ids)} spans.")

# Delete each span by its row id.
delete_resp = requests.post(
    f"{API_URL}/v1/project_logs/{PROJECT_ID}/insert",
    headers=headers,
    json={"events": [{"id": span_id, "_object_delete": True} for span_id in span_ids]},
)
deleted_rows = delete_resp.json().get("row_ids", [])
print(f"Deleted {len(deleted_rows)} spans.")

Important behaviors

  • Delete the whole trace, not just the root span. Deleting a root span deletes only that one span. The child spans remain orphaned and still show up in the UI and BTQL. To fully remove a trace, query for all ids under its root_span_id and delete each one (as shown above).
  • Deletions are eventually consistent. Rows may briefly reappear after a delete and take a few seconds plus a page refresh to disappear. This is more noticeable right after deletion, not a sign the delete failed.
  • Use the correct API URL. For hosted Braintrust, use https://api.braintrust.dev. For self-hosted or hybrid deployments, use the data plane API URL from Settings > Data plane. Being able to insert logs confirms the URL only for writes; deletion uses the same endpoint but still requires valid row ids.
  • {project_id} must be the UUID, not the project name (e.g., 914981cd-29bc-4c94-936e-74bd91d8bef2). Passing the project name fails the request.

Verify a deletion

Re-run the BTQL lookup after deleting. If the delete succeeded, the query returns no rows for those IDs (allow a few seconds for consistency):
check = requests.post(
    f"{API_URL}/btql",
    headers=headers,
    json={"query": f"select id from project_logs('{PROJECT_ID}') where root_span_id = '{trace_root_span_id}'"},
)
print(check.json().get("data", []))  # Should be empty
If rows still persist after deleting by verified row ids, collect the delete response row_ids, a before/after BTQL result for one root_span_id, the request timestamp, the project ID, and your SDK version, and escalate.

Notes

  • For recurring cleanup instead of one-off deletion, use a data retention automation to delete logs older than a configured window.

References