Skip to content

Add performance report methods - #2078

Merged
manuaero merged 16 commits into
developfrom
ms/performance-api
Oct 12, 2026
Merged

manuaero merged 16 commits into
developfrom
ms/performance-api

Conversation

@manuaero

@manuaero manuaero commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Description

The numbers on a project's Performance page and on the workspace Monitor are being made available over REST, under /api/v1/performance (Labelbox/intelligence#31542). This adds the SDK methods that read them. No ticket.

# What can be queried
client.get_performance_metrics()

# One project
project.get_performance_metric("labels_created", "2026-01-01", "2026-01-31", interval="week")
project.get_labeler_performance("2026-01-01", "2026-01-31", sort_by="labels_created", descending=True)
project.get_reviewer_performance("2026-01-01", "2026-01-31")
project.get_performance_report_download("2026-01-01", "2026-01-31")

# Across the workspace (the Monitor)
client.get_workspace_performance_metric("labels_created", "2026-01-01", "2026-01-31")
client.get_workspace_labeler_performance("2026-01-01", "2026-01-31")

What a caller gets:

  • Typed results. Pydantic models with snake_case attributes: PerformanceMetricSeries (with PerformanceTimeBucket or PerformanceScoreBucket buckets), ProjectLabelerPerformance, ProjectReviewerPerformance, WorkspaceLabelerPerformance, PerformanceReportDownload. Fields the API adds later are ignored, so an older SDK keeps working.
  • Paged tables without paging code. The two project tables return PerformanceRows: iterate it and it fetches a page at a time; .total and .cached_at read the first page once. Nothing is requested until it is read.
  • Flexible inputs. Dates take a date, a datetime or a "2026-01-31" string and are sent as UTC days, which is how the reports bucket. Filters (user_ids, user_group_ids, batch_ids, project_ids) take IDs or SDK objects, whether the object carries its ID as uid (most of the SDK) or as id (UserGroup). A value with no ID raises TypeError before any request is made. interval and deleted_labels take the enums or plain strings.
  • The SDK's own exceptions. 400 → InvalidQueryError naming each parameter the API refused, 401 → AuthenticationError, 403 → AuthorizationError, 404 → ResourceNotFoundError, 429 → ApiLimitError, 5xx → InternalServerError.

Implementation notes:

  • Everything lives in labelbox/schema/performance.py; the methods on Client and Project are thin wrappers. It uses the client's existing connection and rest_endpoint, like the other REST-backed methods.
  • IDs and metric names are URL-quoted as path segments, so a value can never change the path that is called.
  • Requests have a 120 second timeout: reports are computed on request and a wide date range can be slow.
  • Project.labeler_performance() is unchanged. It is a different, older report with no period; the new method's docstring says so.
  • Sphinx: new docs/labelbox/performance.rst, listed in the index. The user-facing guide is Labelbox/lbx-docs-mintlify#295.
  • Percentages and agreement scores are fractions from 0 to 1, as the API returns them; every class that carries one says so.

Do not release before the API is live. These methods call endpoints that exist only once Labelbox/intelligence#31542 is deployed. Until then they raise ResourceNotFoundError.

Type of change

  • New feature (non-breaking change which adds functionality)

All Submissions

  • Have you followed the guidelines in our Contributing document?
  • Have you provided a description?
  • Are your changes properly formatted?

New Feature Submissions

  • Does your submission pass tests?
  • Have you added thorough tests for your new feature?
  • Have you commented your code, particularly in hard-to-understand areas?
  • Have you added a Docstring?

Changes to Core Features

  • Have you written new tests for your core changes, as applicable?
  • Have you successfully run tests with your changes locally?
  • Have you updated any code comments, as applicable?

What was run

  • pytest tests/unit/schema/test_performance.py: 46 tests, all passing, plus tests/unit/test_client.py and tests/unit/test_project.py (63 in total). They mock the HTTP session and check the exact URL and query string sent, the parsed results, paging, and the error mapping. Response fixtures are the shapes asserted by the API's own tests.
  • ruff format --check clean; ruff check clean under the repo's rule set; mypy clean on performance.py, client.py and project.py.

What was not run

  • No integration tests are added, because the endpoints are not deployed to staging yet and the suite would fail. They should follow once the API PR is live. Nothing here has been run against a real API.
  • rye is not installed on the machine this was written on, so the checks above were run directly with pytest, ruff and mypy in a Python 3.12 environment, not through rye run.

Added since this PR was opened

Follows the API changes in Labelbox/intelligence#31542.

  • Whose member did the work. Labeler and reviewer rows carry member_type: "internal" for a member of your organization, "external" for a member of another one, such as a workforce provider. PerformanceMemberType names the two values.

  • The Monitor's Member Type and "owned by" filters. get_workspace_performance_metric and get_workspace_labeler_performance take member_type and owner_organization_ids.

  • The workspace table is paged. get_workspace_labeler_performance now returns PerformanceRows, fetched a page at a time like the project tables, with sort_by, descending, page_size and use_cache. It returned the whole table as a list. Nothing was released with the old shape.

  • Retries. A failure of the service (5xx) or a timeout is tried again on the schedule Client.execute uses (google.api_core.retry). When it stops trying, the error raised is the failure itself (InternalServerError, TimeoutError), not a retry error. A connection failure is raised as NetworkError, as elsewhere in the SDK. A refused request (4xx, including 429) is not repeated. The retry deadline is twice the 120-second request timeout, so a request that timed out is tried once more; under the library's default deadline (also 120 seconds) it never was, which Bugbot caught.

  • An empty filter is refused. user_ids=[] (or any other ID list) raises ValueError before anything is sent. It was treated as "no filter" and reported on everyone. None still means no filter.

  • Errors do not point back at each other. When retries ran out, the failure was raised from the retry error, which already named it as its cause. Traceback printers that follow the chain (rich's among them) never finished. It is now raised outside the handler.

  • A second pass over PerformanceRows reads the table again. It started from the first page kept from the first pass and continued with newly fetched pages.

  • sort_by is spelled for the API here, not by pydantic's to_camel, which on older 2.x releases lower-cases a name already in camel case.

  • Sorting the project tables by email is no longer documented; the API does not offer it.

The last five came out of an independent review of the module.

Tests: 89 in tests/unit/schema/test_performance.py (106 with test_client.py and test_project.py), run with the response shapes the API returns. Still no integration tests: the endpoints are not on staging yet.

CI

The unit-data and integration jobs fail on develop itself, on tests this change does not touch. Those are fixed in #2079, which is merged into this branch so that both can be checked together. If #2079 lands first, this pull request's diff shrinks back to the performance module.

Also added since this was opened, after testing against production: a page takes at most 100 rows; score ranges are fractions; a project that is gone has no name or owner; skipped_project_ids on a workspace metric split by owner.

🤖 Generated with Claude Code


Note

Medium Risk
Large new read-only API surface with org- and project-level performance permissions and paging/retry behavior; methods will 404 until the backing REST API is deployed.

Overview
Adds SDK support for Performance page and workspace Monitor data via new /api/v1/performance REST calls, implemented in labelbox.schema.performance with thin wrappers on Client and Project.

Callers can list metrics, fetch time/score series with filters (users, groups, batches, deleted labels, intervals), page labeler/reviewer tables through PerformanceRows, download project report files, and query workspace-wide metrics and labeler tables (including member_type and owner_organization_ids). Responses are typed Pydantic models (exported from the package), with URL-safe path segments, 120s timeouts, transient 5xx/timeout retries, strict rejection of empty ID filters, and HTTP status mapped to SDK exceptions.

Documentation adds docs/labelbox/performance.rst to the Sphinx index. Client also improves the ontology feature-schema error message with status code and body text.

Test/CI hardening bundled in the same branch: skip deprecated MEA prediction-import integration tests; retry model-run export until labels appear; embedding cap healing treats MalformedQueryException; integration tests tolerate masked GraphQL validation errors, global-key index lag after delete, label listing delay, and flaky org resource-tag counts.

Reviewed by Cursor Bugbot for commit 7d62edc. Bugbot is set up for automated code reviews on this repo. Configure here.

The numbers on a project's Performance page and on the workspace Monitor
are now available over REST (/api/v1/performance). This adds the SDK
methods that read them:

  client.get_performance_metrics()
  client.get_workspace_performance_metric(metric, start_date, end_date)
  client.get_workspace_labeler_performance(start_date, end_date)
  project.get_performance_metric(metric, start_date, end_date)
  project.get_labeler_performance(start_date, end_date)
  project.get_reviewer_performance(start_date, end_date)
  project.get_performance_report_download(start_date, end_date)

Results are typed models (PerformanceMetricSeries, ProjectLabelerPerformance
and so on) with snake_case attributes. The two project tables come back as
PerformanceRows, which fetches a page at a time while iterating and exposes
the table's total.

Dates can be a date, a datetime or a "2026-01-31" string and are sent as
UTC days, which is how the reports bucket. ID filters take IDs or SDK
objects. API errors are raised as the SDK's own exceptions, and a refused
query names the parameters at fault.

Project.labeler_performance() is unchanged; it is a different, older
report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The performance reports return agreement scores and rework and approval
rates as fractions (0.9 for 90%). Say so on every class that carries one,
and use realistic values in the test rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Filters are documented to take IDs or SDK objects, but only objects with a
`uid` were understood. UserGroup is a pydantic model that carries its ID as
`id`, so passing groups to `user_group_ids` failed while joining the list.

Read `uid`, then `id`, and refuse a value that has neither with a message
that says what was passed, before any request is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the performance REST API:

- Labeler and reviewer rows carry `member_type`: whether the member belongs
  to your organization (`internal`) or to another one, such as a workforce
  provider (`external`). `PerformanceMemberType` names the two.
- `get_workspace_performance_metric` and `get_workspace_labeler_performance`
  take `member_type` and `owner_organization_ids`, the Monitor's Member
  Type and "owned by" filters.
- `get_workspace_labeler_performance` returns `PerformanceRows`, fetched a
  page at a time like the project tables, with `sort_by`, `descending`,
  `page_size` and `use_cache`. It returned the whole table as a list.
- A failure of the service (5xx) or a timeout is tried again on the
  schedule `Client.execute` uses. When it stops trying, the error raised is
  the failure itself, not a retry error. A timeout is raised as
  `lbox.exceptions.TimeoutError` and a connection failure as `NetworkError`,
  as elsewhere in the SDK. A refused request (4xx) is not repeated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ca0d0e0. Configure here.

A request waits up to 120 seconds, and the retry schedule's deadline, which
counts from the first attempt, was the library's default of 120 seconds. So
by the time a request timed out the deadline had passed, and it was never
tried again: only quick failures were.

The deadline is now twice the request timeout: one more attempt after a
timeout, and no third.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- When retries run out, the failure is raised without being chained back
  to the retry error. The retry error already names the failure as its
  cause, so `raise cause from error` made the two point at each other, and
  traceback printers that follow the chain (rich's among them) never
  finish.
- An empty list for a filter (`user_ids=[]`) raises `ValueError` instead
  of leaving the filter off. It is nearly always a list that came out
  empty, and leaving the filter off reported on everyone. `None` still
  means no filter.
- Iterating `PerformanceRows` a second time reads the table again from its
  first page. The first page is kept so that reading `total` and then the
  rows costs one request; a second pass started from that old page and
  continued with newly fetched ones.
- `sort_by` is turned into the API's spelling here instead of by
  pydantic's `to_camel`, which on older 2.x releases lower-cases a name
  that is already in camel case.
- Project tables no longer document sorting by email; the API does not
  offer it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the API after it was tested against production: the performance
service refuses a page of more than 100 rows, and the score histograms'
ranges are now given as fractions from 0 to 1, like the scores themselves.
Docstrings only; the default page size (50) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workspace table keeps the rows of a project that can no longer be
found, and the API now returns null for its name and owner where it
returned the text "N/A". The three attributes are optional.

Also says that an Alignerr account is external to everyone, which testing
against production showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workspace metric narrowed by owning organization now says, in
`skipped_project_ids`, which of those owners' projects it does not count
because the caller may not view everyone's performance in them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `unit-data` and `integration` jobs have failed on develop, and so on
every pull request, for five separate reasons.

MEA prediction import is gone from the backend. The prediction import
service rejects every import of that type ("9 FAILED_PRECONDITION: MEA
prediction import has been deprecated"), so the nine tests that call
`ModelRun.add_predictions()` are skipped, with that reason. The tenth test
in the file only reads a model run; it passes and stays enabled.

The embedding fixture could not heal itself. The staging organization is
at the cap of 10 custom embeddings. The fixture sweeps stale ones and
retries when it hits the cap, but only inside `except LabelboxError`, and
since `Client.create_embedding` became a GraphQL mutation the cap arrives
as `MalformedQueryException`, which is not a `LabelboxError`. So the sweep
never ran, and every test that needs an embedding errored at setup: three
in test_embedding.py, test_export_embeddings_custom and test_mmc_all. The
helper now catches both, for the create and for the delete of a stale
embedding that another job has already removed.

Validation messages are masked. Deployed API environments replace the
message of every GraphQL validation error with "GraphQL validation error".
`test_semantic_error` and `test_query_complexity_error` expected the
detailed text; they now accept the masked message, or the detailed one from
an API that does not hide schema details. Nothing else is accepted.

Two tests raced the backend. A global key is not free the moment its data
row is deleted (the import checks keys against the search index, which
drops the row a little later), and three labels are not listed the moment
they are created. Both tests now wait for the condition they depend on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
manuaero and others added 2 commits October 11, 2026 22:19
A newer formatter than the one CI runs had restyled one statement that
this change does not touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
manuaero and others added 3 commits October 11, 2026 22:31
Both failed once in the first run with the other fixes in place.

`test_update_project_resource_tags` compared the number of resource tags
in the organization before and after creating one (346 > 347). The suite
runs for five Python versions at once against one organization, creating
and deleting tags, so the count can go either way. It now looks for the
two tags it created.

`test_model_run_project_labels` read the first label of each data row from
a model run export taken straight after `upsert_labels`. The export
catches up a little later, and until then a data row comes out with no
label for the run (IndexError). It now exports again while any is missing,
for up to three minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Client.is_feature_schema_archived` raised "Failed to get the feature
schema archived status." for any answer other than 200 or 404, without
the status or the body. `test_feature_schema_is_archived` failed that way
once against staging and the log could not say why (a rate limit, or a
server error). The message now carries both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@manuaero
manuaero merged commit c3e932a into develop Oct 12, 2026
25 checks passed
@manuaero
manuaero deleted the ms/performance-api branch October 12, 2026 06:31

This branch was previously deployed

1 inactive deployment
Test-PyPI — 7d62edc1 Deployed Oct 12, 2026 by manuaero via test-pypi #2531
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