Skip to content

fix(ai): let Prompts.get_all() batch without a label - #964

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixaiprompts-let-promptsget_all-batch-0b7352
Draft

posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixaiprompts-let-promptsget_all-batch-0b7352

Conversation

@posthog

@posthog posthog Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

  • Who is hurt: every prompt-management user who does not use labels. get_all() took a required keyword-only label, so those apps had no batch entry point and fell back to one get() request per prompt, on every cache cycle. One customer hit the hourly prompt-fetch cap on staging and opened a support ticket with a patch.
  • label=None was not an escape hatch. It went into urlencode unguarded and reached the server as the literal string "None".
  • The server side already supports it. GET /api/environments/@current/llm_prompts/ returns the latest version of every prompt when label is omitted.
  • Requests per cache cycle go from N prompts to 1 (plus pagination). A 20-prompt app on a 60-second TTL drops from 1,200 to 60 requests an hour.

Closes #959 (same API shape, filed for exactly this).

Important

This is a public API change, and CONTRIBUTING.md asks for the shape to be agreed on the issue before implementation. #959 has no maintainer reply yet, so this PR stays a draft: please confirm the signature on the issue (or here) before merging. The change is additive, so existing get_all(label=...) callers are unaffected.

💚 How did you test it?

Unit testsuv run pytest posthog/test/ai/test_prompts.py (79 pass). The 8 pre-existing get_all tests pass unchanged, which is the regression check on the labeled path. Two new tests cover the unlabeled path: no label param in the URL, latest versions returned, the cache seeded under the key get(name) reads, and the error wording.

End-to-end against a real HTTP server — a throwaway http.server standing in for the list and single-prompt endpoints, so URL construction, pagination following, auth header and cache keying were exercised without mocks:

step requests
get_all() over 5 prompts, 2 per page 3
5 × get(name) straight after 0
5 × get(name) on a cold cache (the old shape) 5

Edge cases considered: a prompt carrying no label at all, and a prompt whose label points at an earlier version than the latest — both belong in an unlabeled result, so neither may be dropped by the "moved"/"absent" label logic. Pagination links off the configured host, the page-count backstop, and malformed rows leaving the cache untouched are all on the shared path and still covered.

Also run: ruff format --check ., ruff check ., mypy on the changed module, make public_api_check, and the full pytest suite — 2726 pass. Three failures in test_anthropic.py / test_callbacks.py are pre-existing: they call the live Anthropic/OpenAI APIs and fail identically on a clean checkout of main.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous — no human drove this, so the PR is left unassigned for the owning team to triage.

Written by Claude Opus 5 in PostHog Desktop, from the inbox report linked below.

  • Scope. The report named two adjacent causes: the cache key includes the label, so the same prompt fetched labeled and unlabeled costs two entries and two requests; and self._cache is per-Prompts instance, so a client built per request starts cold. Both are left alone. Deduplicating the cache key would be wrong — a label can point at a version that is not the latest, so the two keys are not interchangeable — and a process-wide cache is a design call, not a bug fix. Neither is needed for the reported symptom.
  • Rejected in review. A first pass special-cased the unlabeled path with an early continue inside the validation loop, which left two separate "keep this row" exits and made the "no row resolved the label" guard dead-but-live. It now scopes the whole label-resolution block under one if label is not None:, so the loop has a single keep path. A suggestion to widen _row_label_state(row, None) to return "resolved" was rejected: it would put unlabeled semantics into a helper whose only job is comparing against a requested label.
  • Also rejected: paginating pages 2..N concurrently off the first page's count. Real gain only past a page or two, and out of scope here.

Created with PostHog Desktop from this inbox report.

`get_all` took a required keyword-only `label`, so an app that does not use
labels had no batch entry point and fell back to one `get()` request per
prompt on every cache cycle. Passing `label=None` sent the literal string
"None" over the wire.

`label` is now optional. Without it the list endpoint returns the latest
version of every prompt, cached under the same key `get(name)` reads. The
old-server label guards stay on the labeled path, where the label is the
thing an old server can ignore.

Refs #959

Generated-By: PostHog Desktop
Task-Id: afd5da60-e8ce-4008-bb05-ea28a501b088
@posthog

posthog Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

🦔 PostHog Review reviewed this pull request

Found 0 must fix, 1 should fix, 0 consider.

Published 1 finding (view the review).

Resolved comments: 1 left for you

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-09-16 02:36:58 UTC
Duration: 256405ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint 517ms
Endpoint And Method.Does Not Use Legacy Endpoints 511ms
Required Headers.Has Authorization Bearer Header 510ms
Required Headers.Has Content Type Json 510ms
Required Headers.Has Posthog Sdk Info Format 510ms
Required Headers.Has Posthog Attempt Header 510ms
Required Headers.Has Posthog Request Id 510ms
Required Headers.Has Posthog Request Timestamp 511ms
Required Headers.Has User Agent 510ms
Body Format.Body Has Created At And Batch 510ms
Body Format.No Api Key In Body 511ms
Body Format.No Sent At In Body 510ms
Event Format.Event Has Required Root Fields 509ms
Event Format.Event Uuid Is Valid 511ms
Event Format.Event Timestamp Is Rfc3339 510ms
Event Format.Distinct Id Is String 510ms
Event Format.Distinct Id At Root Not Properties 510ms
Event Format.Custom Properties Preserved 510ms
Event Format.Set Properties Preserved 510ms
Event Format.Set Once Properties Preserved 510ms
Event Format.Groups Properties Preserved 510ms
Event Format.Sdk Generates Uuid If Not Provided 511ms
Event Format.Event Has Required Root Fields Batch 513ms
Event Format.Event Uuid Is Valid Batch 514ms
Event Format.Event Timestamp Is Rfc3339 Batch 513ms
Event Format.Distinct Id Is String Batch 514ms
Event Format.Distinct Id At Root Not Properties Batch 513ms
Event Format.Custom Properties Preserved Batch 513ms
Event Format.Set Properties Preserved Batch 514ms
Event Format.Set Once Properties Preserved Batch 514ms
Event Format.Groups Properties Preserved Batch 513ms
Event Format.Sdk Generates Uuid If Not Provided Batch 513ms
Batch Behavior.Multiple Events In Single Batch 518ms
Batch Behavior.Batch Envelope Smoke 515ms
Batch Behavior.Flush With No Events Sends Nothing 506ms
Batch Behavior.Flush At Triggers Batch 1011ms
Batch Behavior.Created At Reflects Batch Creation Time 513ms
Deduplication.Generates Unique Uuids 517ms
Deduplication.Different Events Same Content Different Uuids 512ms
Deduplication.Preserves Uuid On Retry 6519ms
Deduplication.Preserves Timestamp On Retry 6518ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 6523ms
Deduplication.No Duplicate Events In Batch 518ms
Header Behavior On Retry.Attempt Header Starts At One 510ms
Header Behavior On Retry.Attempt Header Increments On Retry 13528ms
Header Behavior On Retry.Request Id Preserved On Retry 6519ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3019ms
Header Behavior On Retry.Request Timestamp Changes On Retry 6519ms
Response Format Validation.Success Response Has Uuid Keyed Results 511ms
Response Format Validation.Success Response Has Ok For Each Event 515ms
Response Format Validation.Success No Retry After When All Ok 512ms
Response Format Validation.Success Retry After Present When Retry Events 1515ms
Response Format Validation.Success No Retry After When Drop Only 513ms
Response Format Validation.Response Echoes Request Id 511ms
Retry Behavior.Retries On 408 6519ms
Retry Behavior.Retries On 500 6520ms
Retry Behavior.Retries On 503 8523ms
Retry Behavior.Retries On 504 6519ms
Retry Behavior.Retryable Errors Have Retry After 3517ms
Retry Behavior.Respects Retry After On Retryable Error 11523ms
Retry Behavior.Does Not Retry On 400 2513ms
Retry Behavior.Does Not Retry On 401 2513ms
Retry Behavior.Does Not Retry On 402 2512ms
Retry Behavior.Does Not Retry On 413 2513ms
Retry Behavior.Does Not Retry On 415 2513ms
Retry Behavior.Non Retryable Errors Have No Retry After 2512ms
Retry Behavior.Implements Backoff 22537ms
Retry Behavior.Max Retries Respected 22534ms
Partial Batch Handling.Handles 200 Full Success 2511ms
Partial Batch Handling.Handles 200 With All Ok 3517ms
Partial Batch Handling.Does Not Retry Dropped Events 3514ms
Partial Batch Handling.Does Not Retry Limited Events 3516ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 6521ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 6522ms
Partial Batch Handling.Retries Only Retry Events From Partial 6519ms
Partial Batch Handling.Partial Retry Preserves Uuids 6521ms
Partial Batch Handling.Partial Retry Attempt Header Increments 6518ms
Partial Batch Handling.Partial Retry Request Id Preserved 6517ms
Partial Batch Handling.Respects Retry After On Partial 8520ms
Partial Batch Handling.Unknown Result Treated As Terminal 3516ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 3519ms
Compression.Sends Gzip Content Encoding 511ms
Compression.No Content Encoding When Disabled 511ms
Compression.Compressed Body Is Decompressible 509ms
Error Handling.Does Not Retry On Unknown 4Xx 2513ms
Event Options.Cookieless Mode Override 510ms
Event Options.Disable Skew Correction Override 510ms
Event Options.Process Person Profile Override 511ms
Event Options.Product Tour Id Override 510ms
Event Options.Unset Options Omitted 510ms
Event Options.Options Override In Batch 513ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 510ms
Geoip And Historical Migration.Historical Migration Set In Body 511ms
Geoip And Historical Migration.Historical Migration Absent By Default 510ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 13ms
Request Payload.Flags Request Uses V2 Query Param 9ms
Request Payload.Flags Request Hits Flags Path Not Decide 10ms
Request Payload.Flags Request Omits Authorization Header 10ms
Request Payload.Token In Flags Body Matches Init 9ms
Request Payload.Groups Round Trip 10ms
Request Payload.Groups Default To Empty Object 9ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 10ms
Request Payload.Disable Geoip Omitted Defaults To False 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 9ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 509ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 14ms
Request Lifecycle.Mock Response Value Is Returned To Caller 9ms
Retry Behavior.Retries Flags On 502 312ms
Retry Behavior.Retries Flags On 504 314ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 511ms

@posthog

posthog Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PostHog Review

Found 1 should fix.

Comment thread posthog/ai/prompts.py
Comment on lines +691 to +694
params = {"token": self._project_api_key, "content": "full"}
if label is not None:
params["label"] = label
query = urllib.parse.urlencode(params)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unlabeled batch fetches lose prompt-fetch usage events

should_fix compatibility

Issue description

The new unlabeled path calls the list endpoint without label. The deployed server emits $llm_prompt_fetched for labeled list calls only. Repeated get(name) calls emit one event per prompt. An app that switches to get_all() therefore stops reporting these fetches. Prompt-only teams can also disappear from AI observability usage reports. The required server fix remains an open draft.

Why we think it's a valid issue
  • Checked: the new unlabeled request build at posthog/ai/prompts.py:691-694, the whole posthog-python tree for any client-side fetch event, the server list() handler on PostHog/posthog master, the ingestion billable-event list, and the AI observability usage report task.
  • Found: the server tracks list fetches only under a label. On master, list() calls self._track_labeled_list_fetches(prompts, label) inside an if label: guard (posthog/api/llm_prompt.py:685-689), while the single-prompt action calls self._track_prompt_fetch(prompt) unconditionally (posthog/api/llm_prompt.py:358). The premise holds: the path this PR adds produces no $llm_prompt_fetched event, and the path it replaces produces one per prompt per cache cycle.
  • Found: the gap is still open. The most recent commit on posthog/api/llm_prompt.py is 7d776ae0 feat(aio): report prompt fetches on labeled list calls (#98183). feat(aio): report prompt fetches on unlabeled list calls posthog#100690 reports state: OPEN, isDraft: true, mergedAt: null, so the unlabeled tracking is not deployed.
  • Found: the SDK sends nothing of its own to compensate. A search for llm_prompt_fetched across posthog-python returns no match, and _maybe_capture_error reports exceptions only.
  • Found: the loss reaches team selection, not only a count. AI_OBSERVABILITY_REPORT_TRIGGER_EVENTS includes $llm_prompt_fetched (posthog/tasks/ai_observability_usage_report.py:47) and feeds get_teams_with_ai_events(...) at line 911. A team that uses prompt management but emits no $ai_* events drops out of the report after it moves to the unlabeled batch call.
  • Found: this file already records server-version dependencies, so a rollout note fits the existing convention. The get_all docstring names "PostHog releases from before September 2026" for the labeled path (posthog/ai/prompts.py:395-397).
  • Impact: an app that adopts the new path stops reporting prompt fetches until the server change deploys. That telemetry is what shows rate-limit headroom, which is the exact signal this PR's own motivation rests on.
  • Priority: lowered to should_fix. $llm_prompt_fetched sits in NON_BILLABLE_EVENTS in nodejs/src/ingestion/common/usage-records/billable-events.ts:20, so no billing and no customer-facing result changes. No code in this repository is wrong, the correction is server-side and takes effect for every client as soon as it deploys, and the requested server contract test belongs in PostHog/posthog. The action for this PR is release coordination plus a note in the changeset, not a merge blocker.
Suggested fix

Deploy PostHog/posthog#100690 before this SDK release. Record this rollout dependency in the changeset. Add a server contract test that proves an API-key unlabeled list request emits one event for each returned prompt.

Prompt to fix with AI (copy-paste)
## Context
@posthog/ai/prompts.py#L691-694

<issue_description>
The new unlabeled path calls the list endpoint without `label`. The deployed server emits `$llm_prompt_fetched` for labeled list calls only. Repeated `get(name)` calls emit one event per prompt. An app that switches to `get_all()` therefore stops reporting these fetches. Prompt-only teams can also disappear from AI observability usage reports. The required server fix remains an open draft.
</issue_description>

<issue_validation>
- **Checked:** the new unlabeled request build at posthog/ai/prompts.py:691-694, the whole posthog-python tree for any client-side fetch event, the server `list()` handler on PostHog/posthog master, the ingestion billable-event list, and the AI observability usage report task.
- **Found:** the server tracks list fetches only under a label. On master, `list()` calls `self._track_labeled_list_fetches(prompts, label)` inside an `if label:` guard (posthog/api/llm_prompt.py:685-689), while the single-prompt action calls `self._track_prompt_fetch(prompt)` unconditionally (posthog/api/llm_prompt.py:358). The premise holds: the path this PR adds produces no `$llm_prompt_fetched` event, and the path it replaces produces one per prompt per cache cycle.
- **Found:** the gap is still open. The most recent commit on posthog/api/llm_prompt.py is `7d776ae0 feat(aio): report prompt fetches on labeled list calls (#98183)`. PostHog/posthog#100690 reports `state: OPEN`, `isDraft: true`, `mergedAt: null`, so the unlabeled tracking is not deployed.
- **Found:** the SDK sends nothing of its own to compensate. A search for `llm_prompt_fetched` across posthog-python returns no match, and `_maybe_capture_error` reports exceptions only.
- **Found:** the loss reaches team selection, not only a count. `AI_OBSERVABILITY_REPORT_TRIGGER_EVENTS` includes `$llm_prompt_fetched` (posthog/tasks/ai_observability_usage_report.py:47) and feeds `get_teams_with_ai_events(...)` at line 911. A team that uses prompt management but emits no `$ai_*` events drops out of the report after it moves to the unlabeled batch call.
- **Found:** this file already records server-version dependencies, so a rollout note fits the existing convention. The `get_all` docstring names "PostHog releases from before September 2026" for the labeled path (posthog/ai/prompts.py:395-397).
- **Impact:** an app that adopts the new path stops reporting prompt fetches until the server change deploys. That telemetry is what shows rate-limit headroom, which is the exact signal this PR's own motivation rests on.
- **Priority:** lowered to `should_fix`. `$llm_prompt_fetched` sits in `NON_BILLABLE_EVENTS` in nodejs/src/ingestion/common/usage-records/billable-events.ts:20, so no billing and no customer-facing result changes. No code in this repository is wrong, the correction is server-side and takes effect for every client as soon as it deploys, and the requested server contract test belongs in PostHog/posthog. The action for this PR is release coordination plus a note in the changeset, not a merge blocker.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Deploy https://github.com/PostHog/posthog/pull/100690 before this SDK release. Record this rollout dependency in the changeset. Add a server contract test that proves an API-key unlabeled list request emits one event for each returned prompt.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Escalating: the gap is real, but it closes in the server repository and depends on a release-sequencing call a human must make.


  • I confirmed this SDK sends no prompt-fetch event of its own, so the unlabeled batch path reports nothing until the server side changes.
  • The server change is still an open draft, so the dependency holds today.
  • A human must decide whether to hold this SDK release until that server change deploys, or ship now and accept the gap.
  • I did not add the note to the changeset. That file becomes public release notes, and its wording depends on the decision above.
  • The requested contract test belongs in the server repository, which I must not change from this pull request.
How this was verified

No code changed, so no lint or tests were run. Checks made instead: read the unlabeled request build and the error-capture helper in the prompts module, searched the whole repository for any client-side prompt-fetch event (no match), and queried the state of the referenced server pull request (still open and draft). Working tree left clean.

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.

Allow Prompts.get_all() without a label, to batch-fetch the latest version of every prompt

0 participants