From 540acb962ffc4c4ecc63d3a9ee6c2c07010ad5ae Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 09:08:14 +0000 Subject: [PATCH 01/11] Add public API reference for Inspector issue endpoints Documents the three GET endpoints on api.avo.app that expose Inspector data outside the web app: the issue list (/inspector/issues/v5), a single issue (/inspector/issues/v3/{issueId}), and event-shape variations (/inspector/issues/{issueId}/variations). Written for an engineer or agent developer integrating with a service account or OAuth token rather than for a web-app user, so every query parameter, response field, status code and silent fallback is spelled out, with working curl examples. Covers the semantics that cause wrong integrations when assumed away: the 24h count-gated window and the three meanings of an empty array (including HTTP 200 on a database failure), eventCount vs issueCount, issueId instability vs sharedIssueId, the 400-row variations cap and variationsTruncated, raw SDK property names, the materialization lag, and the absence of variant attribution. Verified against the implementation. Three points differ from the original spec and are documented as the code behaves: auth is not uniform (the /v3/ single-issue route takes a Firebase ID token only and rejects service-account Basic and OAuth JWT), a foreign-workspace credential gets 403 rather than 404, and sharedIssueId is insulated from new observed types and source but not from tracking-plan edits in general. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/_meta.js | 5 +- .../reference/public-api/inspector-issues.mdx | 646 ++++++++++++++++++ pages/reference/public-api/overview.mdx | 1 + 3 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 pages/reference/public-api/inspector-issues.mdx diff --git a/pages/reference/public-api/_meta.js b/pages/reference/public-api/_meta.js index e37f8c77a..1a7526021 100644 --- a/pages/reference/public-api/_meta.js +++ b/pages/reference/public-api/_meta.js @@ -1,3 +1,4 @@ export default { - "overview": "Overview" -}; \ No newline at end of file + "overview": "Overview", + "inspector-issues": "Inspector Issues" +}; diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx new file mode 100644 index 000000000..33d06bc3c --- /dev/null +++ b/pages/reference/public-api/inspector-issues.mdx @@ -0,0 +1,646 @@ +import { Callout } from 'nextra/components'; + +# Inspector Issues + +_Read Inspector issues and observed event shapes over HTTP_ + +Three GET endpoints expose Inspector data outside the Avo web app: a list of issues, a single issue, and the observed event shapes ("variations") behind an issue. They are documented together because they share a base URL and a workspace-scoping model — but **not** an authentication model, and the differences between them cause most broken integrations. + +This page is written for someone wiring these endpoints into a script, a CI check, or an agent tool. The response body is your only view of the data, so every field, fallback and silent behavior is spelled out below. + +Base URL for all three: `https://api.avo.app` + +## Endpoints + +| # | Method and path | Returns | Reach for it when | +| --- | --- | --- | --- | +| A | `GET /workspaces/:workspaceId/inspector/issues/v5` | The issue **list** — `{"issues": [...]}` | You want the issues currently counting in a workspace. This is the endpoint most integrations need, and the only one that hands you `issueId` values to pass to the other two. | +| B | `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You already have one `issueId` and need per-app-version counts, or a window other than 24 hours. Accepts a Firebase ID token only. | +| C | `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You need the payloads themselves — which property names and types were actually sent — so you can diff the shape causing the issue against the healthy one. | + +`:workspaceId` is the ID of your workspace. You'll find it in the URL of your Avo tab after `/schemas/`. It is also returned as `schemaId` on every response object. + +Endpoint A is the entry point: it is the only endpoint that does not need an id up front, and endpoints B and C both take an `issueId` from its response. There is no endpoint that searches for an issue by event name. + +### Watch the path: `/issues/:issueId` is a different resource + +There is a fourth route in this path space that is easy to hit by accident: + +```Url +GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId +``` + + +**`/issues/:issueId` without `/v3/` resolves a `sharedIssueId`, not an `issueId`.**
+Despite the path segment name, this route looks up `shared_issue_id`. Because a shared issue spans sources, it returns a **bare JSON array** — one endpoint B object per source — rather than a single object. Query parameters are dropped on this route before they reach the handler, so anything you append is silently ignored. +
+ +Endpoint C sits directly beside that route in the path space, but takes a real `issueId` — the same kind of id endpoint B takes. So `/issues/:id` wants a `sharedIssueId` while `/issues/:id/variations` wants an `issueId`. Passing the wrong kind of id to either returns 404 rather than an error that explains itself. + +## Authentication + +Authentication is **not uniform across these endpoints**. This is the single most common cause of a working list call sitting next to a 401 on the detail call. + +| Credential | A `/issues/v5` | B `/issues/v3/:issueId` | C `/variations` | +| --- | --- | --- | --- | +| Service account Basic (`Authorization: Basic base64(name:secret)`) | ✅ | ❌ 401 | ✅ | +| Avo OAuth JWT (`Authorization: Bearer ...`) | ✅ | ❌ 401 | ✅ | +| Firebase ID token (`Authorization: Bearer ...`) | ✅ | ✅ | ✅ | + + +**The single-issue endpoint accepts a Firebase ID token only.**
+`GET /inspector/issues/v3/:issueId` runs on an older auth stack that requires a `Bearer ` prefix and verifies the token as a Firebase ID token. A service account Basic credential and an Avo OAuth JWT are both **rejected with 401** — including a service account that is correctly registered in the workspace. If you are integrating with a service account, there is no supported way to call endpoint B. +
+ +We recommend the following path for any service-account or OAuth integration: + +1. List issues with **endpoint A** (`/issues/v5`) — it carries every field endpoint B carries, except that `appVersions` is an array of version names rather than per-version counts. +2. For the shapes behind a specific issue, call **endpoint C** (`/variations`) with the `issueId` from step 1. + +Neither A nor C requires an OAuth scope, and neither requires a particular workspace role — any workspace member passes. + +See [authorization header](/public-api/authentication#authenticating-with-avo-api) for how to build the Basic credential from a service account name and secret. The `Basic ` scheme is matched **case-sensitively**, so a lowercase `basic ` is not recognized as a service-account credential — it is treated as a malformed Bearer token and rejected with the message below. + +### Authentication error bodies + +Endpoints A and C share one authenticator, so they return the same four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. + +| Code | Body | Condition | +| --- | --- | --- | +| `401` | `{"message": "Authorization header missing"}` | No `Authorization` header at all. | +| `401` | `{"message": "Authorization header missing or invalid"}` | Unrecognized scheme, empty Bearer token, or any Bearer verification failure — an expired, revoked or wrong-project Firebase token and an invalid Avo OAuth JWT are indistinguishable here. | +| `401` | `{"message": "Invalid authorization"}` | Any Basic failure: bad secret, unknown service account, or a service account not registered in this workspace. | +| `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | + +A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. Endpoint B is on a different stack and answers every auth failure with `401 {"error": "Unauthorized"}`. + +### Workspace scoping + +Every query filters on `schema_id`, so a credential can only ever see its own workspace's rows. That produces two different failures that are easy to confuse: + +- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. On endpoint B, a non-member gets **401 `{"error": "Unauthorized"}`**. +- **404** — the credential is valid *and* scoped to the right workspace, but the requested id isn't in that workspace's rows. Because the lookup is workspace-scoped (`schema_id = $1 AND issue_id = $2`), an id belonging to a different workspace simply doesn't match and returns 404 rather than revealing that the id exists elsewhere. + +So a 403 means "wrong workspace credential" and a 404 means "right credential, id not here" — including the case where the id is real but lives in someone else's workspace. Super-admin credentials bypass both checks. + +### Rate limits + +There is no rate limit on any of these three endpoints. + +## Your first call + +Once you have a credential, listing issues is a single request. Everything else on this page is a refinement of it. + +```sh +$ curl --compressed \ + -H "authorization: Basic " \ + -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/v5" +``` + +That returns `{"issues": [...]}` for the workspace's unresolved issues. From there you can: + +- Narrow the list with `status` and `appVersions` — see [endpoint A's query parameters](#query-parameters). +- Take any `issues[].issueId` and call [endpoint C](#c--listing-event-variations) to see the event shapes behind it. + +Now that you have a working call, the sections below cover what the response does *not* tell you. + +## Before you integrate + +Seven behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Five of them cut across endpoints and are covered here: + +1. [The list is a 24-hour, count-gated view](#the-list-is-a-24-hour-count-gated-view--not-all-issues) — and an empty array has three different meanings. +2. [The time windows are fixed, and the freshest hour is missing](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing). +3. [`eventCount` is not "events affected by this issue"](#eventcount-is-not-events-affected-by-this-issue). +4. [`issueId` is a snapshot handle, not a durable key](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity). +5. [No response tells you which event variant was matched](#no-variant-attribution). + +Two more are specific to endpoint C and are covered in its own section: [`variationsTruncated` is the only reliable completeness signal](#read-variationstruncated-never-count-rows), and [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. + +### The list is a 24-hour, count-gated view — not "all issues" + + +**An empty `issues` array has three legitimate meanings, and you cannot tell them apart from the response.**
+For every status except `Resolved`, an issue is returned only if it was **last seen within 30 days** *and* accumulated **at least one violating occurrence in the last 24 hours**. So `{"issues": []}` may mean:

+1. The workspace is genuinely clean.
+2. **The query failed.** On a Postgres query or connection failure the endpoint returns **HTTP 200 with `{"issues": []}`** — deliberately fail-closed, not a 5xx. A dead database replica is observably identical to a clean workspace.
+3. There are many open issues, none of which fired in the last 24 hours. This is the normal state of a healthy workspace between releases.

+Do not build an alert on "the array is empty" and do not treat an empty array as proof of health. If you need a durable inventory of open issues, poll on a schedule and keep your own record rather than trusting a single response. +
+ +Two further consequences of the count gate: + +- The issue-count join is chained through the event-count join on `app_version`, so the issue must have fired in the last 24 hours *in an app version that also has event counts in the same window*. Otherwise its count is 0 and the issue is dropped from the response. +- For `status=Resolved`, **both** the 30-day gate and the 24-hour count gate are lifted. + +### The time windows are fixed, and the freshest hour is missing + +Endpoint A counts over 24 hours and endpoint C looks back 24 hours. Neither window is configurable — both are literals in the query, with no parameter to widen or shift them. Endpoint B is the only one that takes a window, via its `time` parameter. + + +**Expect roughly an hour of lag, and do not use these endpoints to verify a deploy you just shipped.**
+Both endpoints read continuous aggregates refreshed on a ten-minute schedule with a one-hour end offset. On endpoint A that means roughly an hour of lag on the freshest counts. On endpoint C the aggregate is materialized-only, so **the most recent hour is not visible at all** — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) rather than these endpoints. +
+ +Looking further back is not an option either: on endpoint C both the aggregate and the raw table drop data after 48 hours, so the 24-hour window is always fully covered and there is nothing older to read. + +### `eventCount` is not "events affected by this issue" + + +**`eventCount` is the total 24-hour volume of that event on that source — every shape, healthy ones included.** `issueCount` is the per-issue figure: occurrences in the last 24 hours that actually violated.

+The number worth reporting is the ratio. `issueCount: 1428` against `eventCount: 96204` is a 1.5% violation rate on a high-volume event; reading `eventCount` as "affected events" overstates the blast radius by two orders of magnitude. +
+ +### `issueId` is a snapshot handle; `sharedIssueId` is the identity + +`issueId` is `sha256(schemaId : sourceId : eventName : propertyName : issueType payload)` — the **full** encoded `issueType` payload is hashed. + + +**`issueId` is not stable.** It changes when a tracking-plan edit moves a `propertyId`, `eventId` or `expectedPropertyType` inside the payload, and when a newly observed runtime type is appended to an `InconsistentType` issue's `propertyTypes`. Because `issue_id` is the primary key of the issues table, a changed hash creates a **new row**: the old issue is orphaned with its original `firstSeen`, and the new one starts fresh with no history. Treat `issueId` as a handle valid within one response or one session — safe to pass straight to `/variations`, not safe to persist as a long-lived key in your own database. + + +`sharedIssueId` is `sha256(schemaId : eventName : propertyName : issueType)`, with `sourceId` omitted — that omission is what groups one logical problem across several sources. For `InconsistentType` the volatile `propertyTypes` array is deliberately excluded from the hash as well. + +That stability only goes so far, though. `sharedIssueId` is insulated from **newly observed types** and from **source**, but it is not immune to tracking-plan edits in general. For the five issue types other than `InconsistentType` it still hashes `propertyId` / `eventId` / `expectedPropertyType`, so a tracking-plan edit moves **both** ids. Only `InconsistentType` is fully insulated. + +### No variant attribution + + +**Nothing in any response — JSON or CSV — tells you which event variant Inspector matched against.** There is no variant field in any of these payloads, no variant column in the underlying tables, and variant is not an input to either id hash. The tracking-plan model reaches the matcher already flattened, so variant identity is erased before validation and never reaches the issue row. It cannot be recovered from the response or from the id. If your tracking plan leans on [variants](/data-design/avo-tracking-plan/event-variants), expect to reconcile variant identity yourself. + + +## A — Listing issues + +```Url +GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v5 +``` + +Returns every issue currently counting in the workspace, one row per event-or-property problem **per source**. This is the same data behind the [Inspector issues view](/inspector/inspector-issues-view), and the endpoint to start from: it is the only one you can call without already holding an id. + +Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token. + +**The response is always gzipped.** `Content-Encoding: gzip` is set unconditionally, regardless of what you send in `Accept-Encoding`. Pass `--compressed` to curl, or decompress explicitly in your HTTP client. This is unique to endpoint A — neither B nor C sets `Content-Encoding`. + +### Query parameters + +The handler reads exactly these two, plus `:workspaceId` from the path. + +| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | +| --- | --- | --- | --- | --- | --- | +| `status` | string | Optional | `Unresolved` | `unresolved`, `ignored`, `resolved` — case-insensitive | **Silently falls back to `Unresolved`.** No 400. | +| `appVersions` | comma-separated string | Optional | No version filter | Any strings, split on `,` | An empty string is treated as absent. Unknown versions match nothing, so the issue is excluded. No 400. | + +The three `status` values map to the statuses you set in the Avo web app. Note the naming shift across the three layers, which is the usual source of a silently-empty response: + +| Avo web app label | `status` parameter value | `issueStatus.status.type` in the response | +| --- | --- | --- | +| Unresolved | `unresolved` | `Unresolved` | +| **Ignore** | `ignored` | `Ignored` | +| Resolved | `resolved` | `Resolved` | + +The parameter is lower-cased before matching, so `ignored`, `Ignored` and `IGNORED` are all accepted — but `ignore` is not, and falls back to `Unresolved` without an error. `status=unresolved` also returns issues that have never had a status set. See [issue status](/inspector/inspector-issues-view#issue-status) for what each one means. + + +**There is no `sourceId`, `eventName`, `category`, `tag`, saved view, time range, `sort`, `limit`, `offset`, `cursor` or `format` parameter on this endpoint,** and the query has no `ORDER BY`, `LIMIT` or `OFFSET`. All source, event, category and tag filtering, and all sorting, in the [Inspector issues view](/inspector/inspector-issues-view) happens client-side after the full response is fetched. Plan to filter and sort in your own code. + + +### Response + +The envelope is exactly `{"issues": [...]}` — no `total`, no `nextCursor`, no sibling fields. + +| Field | Type | Notes | +| --- | --- | --- | +| `issueId` | string, never null | sha256 hex. See [snapshot handle](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity) above. | +| `sharedIssueId` | string, never null | sha256 hex. Stable identity across sources. | +| `schemaId` | string | Your workspace ID. | +| `sourceId` | string | A single source — an issue row is per-source. | +| `eventName` | string | The event name as observed. | +| `propertyName` | string \| null | `null` for event-level issue types. | +| `issueType` | object | Tagged union, see below. | +| `oldestAppVersion` | string | | +| `newestAppVersion` | string | | +| `firstSeen` | string (ISO 8601) | Earliest first-seen for this issue row. | +| `lastSeen` | string (ISO 8601) | Max last-seen across the 24-hour buckets, falling back to the last-seen day. | +| `issueCount` | number | Occurrences that **violated**, last 24 hours only. | +| `eventCount` | number | **Total** occurrences of that event on that source in the last 24 hours, all shapes including healthy ones. | +| `appVersions` | string[] | Distinct versions seen in the 24-hour window. Names only — no per-version stats on this endpoint. Can be `[]` under `status=Resolved`, where the count gate is lifted and the issue is returned even with no counts in the window. | +| `issueStatus` | object | `{status, updatedAt: string \| null, updatedBy: string \| null}` | +| `regression` | boolean | Always present. `true` when this issue had been marked **Resolved** and was then observed again — see below. | +| `branchIds` | string[] | Always present; `[]` when the issue is not linked to any branch. | + +#### `regression` + +`regression` is set to `true` when an issue a user had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: + +| `validateIn` | Regresses when the newly observed variation is | +| --- | --- | +| `CurrentAppVersion(v)` | on app version **≥ v** | +| `CustomAppVersion(v)` | on app version **≥ v** | +| `NextAppVersion(v)` | on app version **strictly > v** | +| `Date(t)` | seen **after** `t` | +| `Never` | never — the issue is not reopened and never flagged | + +Two things to code around: + +- **`Ignored` does not produce a regression.** An ignored issue that resurfaces is also moved back to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. +- **The flag is cleared the moment anyone sets the status manually again**, to any value. A newly created issue is never a regression. + +Read `regression` together with `issueStatus.status`: the Avo web app only surfaces it while the status is `Unresolved`, which is the only state it is meaningful in. + +#### `issueStatus.status` + +```json +{ "type": "Unresolved" } +{ "type": "Ignored", "validateIn": { "type": "NextAppVersion", "appVersion": "8.15.0" } } +{ "type": "Resolved", "validateIn": { "type": "Never" } } +``` + +`validateIn` is one of `{"type":"CurrentAppVersion","appVersion":string}`, `{"type":"NextAppVersion","appVersion":string}`, `{"type":"CustomAppVersion","appVersion":string}`, `{"type":"Date","date":ISO 8601}` or `{"type":"Never"}`. + +#### `issueType` + +A tagged union: `type` plus a payload key. The concepts behind each type are documented in [issue types in Inspector](/inspector/issue-types-in-inspector). + +```json +{ "type": "EventNotInTrackingPlan" } +{ "type": "UnexpectedEvent" } +{ "type": "MissingExpectedProperty", "missingExpectedProperty": { "eventId": "...", "propertyId": "...", "propertyName": "..." } } +{ "type": "PropertyTypeInconsistentWithTrackingPlan", "PropertyTypeInconsistentWithTrackingPlan": { "eventId": "..." , "propertyId": "...", "propertyName": "...", "expectedPropertyType": "...", "actualPropertyType": "..." } } +{ "type": "UnexpectedProperty", "unexpectedProperty": { "eventId": "...", "propertyName": "...", "propertyType": "..." } } +{ "type": "InconsistentType", "inconsistentType": { "propertyName": "...", "propertyTypes": ["string", "int"] } } +``` + +**Casing inconsistency to code around:** every payload key is camelCase *except* `PropertyTypeInconsistentWithTrackingPlan`, whose payload key repeats the PascalCase type name. `eventId` inside that payload is nullable; the other payloads' ids are not. + +### Status codes + +| Code | Condition | +| --- | --- | +| `200` | Success (gzipped). | +| `200` with `{"issues": []}` | Also returned on a **Postgres query or connection failure** — fail-closed by design. Not a 5xx. | +| `200`, partially populated | Per-row decode failures are swallowed: a malformed row is dropped from an otherwise successful response, with no marker in the body. | +| `401` | No `Authorization` header, invalid bearer token, bad Basic secret, or a service account not registered in this workspace. See [authentication error bodies](#authentication-error-bodies). | +| `403` | Authenticated, but not an ACL member of `:workspaceId` — including an unknown `:workspaceId`. Body `{"message": "Access denied to workspace"}`. | +| `500` | Body `{"error": "Internal Server Error"}`. A throw after authentication, for example an invalid date reaching the encoder. | + +There is **no 400** for any malformed parameter, and **no 404** on this endpoint. + +### Example + +#### Request + +```sh +$ curl --compressed \ + -H "authorization: Basic " \ + -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/v5?status=unresolved&appVersions=8.14.2,8.13.1" +``` + +#### Response + +```json +{ + "issues": [ + { + "issueId": "2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26", + "sharedIssueId": "8b4d0f6a1c93e57204ab8d1f6e3c9057b24da8f1093c6e5b7d20a41fc8e93b56", + "schemaId": "hAtPI0dEsq", + "sourceId": "9Zq7YAo0R", + "eventName": "Checkout Completed", + "propertyName": "revenue", + "issueType": { + "type": "PropertyTypeInconsistentWithTrackingPlan", + "PropertyTypeInconsistentWithTrackingPlan": { + "eventId": "yT2rKpQ4Xa", + "propertyId": "Bv8nLm1Zq0", + "propertyName": "revenue", + "expectedPropertyType": "float", + "actualPropertyType": "string" + } + }, + "oldestAppVersion": "8.13.1", + "newestAppVersion": "8.14.2", + "firstSeen": "2026-08-11T09:42:18.000Z", + "lastSeen": "2026-08-24T06:00:00.000Z", + "issueCount": 1428, + "eventCount": 96204, + "appVersions": ["8.13.1", "8.14.2"], + "issueStatus": { + "status": { "type": "Unresolved" }, + "updatedAt": null, + "updatedBy": null + }, + "regression": false, + "branchIds": [] + }, + { + "issueId": "c07a5f39b1d84e26af0c93b7512de6a8409fb17c3d6528eab94017f2c85d3b60", + "sharedIssueId": "e51b7d02a94c36f8017be2d5c8390a4f62d17bc03e9584a1f70d2c6b83459e17", + "schemaId": "hAtPI0dEsq", + "sourceId": "kR4vXn8Tb", + "eventName": "Subscription Renewal Reminder Dismissed", + "propertyName": null, + "issueType": { "type": "EventNotInTrackingPlan" }, + "oldestAppVersion": "8.14.2", + "newestAppVersion": "8.14.2", + "firstSeen": "2026-08-22T14:07:55.000Z", + "lastSeen": "2026-08-24T06:00:00.000Z", + "issueCount": 3106, + "eventCount": 3106, + "appVersions": ["8.14.2"], + "issueStatus": { + "status": { "type": "Unresolved" }, + "updatedAt": null, + "updatedBy": null + }, + "regression": false, + "branchIds": [] + } + ] +} +``` + +## B — Retrieving a single issue + +```Url +GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId +``` + +Returns one issue with its counts **broken down per app version**, over a window you choose. Endpoint A gives you app version *names* only, so this is the endpoint to reach for when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. + +`:issueId` is an `issueId` — the value from `issues[].issueId` on endpoint A, not a `sharedIssueId`. + + +**Firebase ID token only.** Service account Basic and Avo OAuth JWT are both rejected with 401 on this endpoint. See [Authentication](#authentication) for the service-account path (list with A, shapes with C). + + +### Query parameters + +| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | +| --- | --- | --- | --- | --- | --- | +| `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | + +`time` also selects the underlying rollup: `24h` reads the eight-hour aggregates, anything else reads the daily aggregate tables. The value is regex-sanitized before use. There is no `format`, no filtering and no pagination on this endpoint. + +### Response + +A **bare object**, not wrapped in an envelope, and **not** gzipped. Field names match endpoint A with one difference: + +| Field | Type | Notes | +| --- | --- | --- | +| `appVersions` | object | A dictionary keyed by version string, **not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | + +Top-level `issueCount` and `eventCount` are the sums across versions; top-level `lastSeen` is the max across versions, falling back to the last-seen day. All other fields carry the same types and nullability as on endpoint A. + +### Status codes + +| Code | Body | Condition | +| --- | --- | --- | +| `200` | The issue object | At least one row matched. | +| `401` | `{"error": "Unauthorized"}` | Missing, invalid, or non-Firebase `Authorization` header — **or** an authenticated caller who is not a member of `:workspaceId`. | +| `404` | `{"error": "Issue Not found"}` | Zero rows for this workspace and id. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | +| `500` | `{"error": "Internal Server Error"}` | Database error. | + +Note the asymmetry with endpoint A: endpoint B propagates a database failure as a 500, while endpoint A swallows the same failure into a 200 with an empty array. If you are health-checking Inspector, B is the endpoint that tells you the truth — but only a Firebase ID token can call it. + +There is no 400 on this endpoint. + +### Example + +#### Request + +```sh +$ curl -H "authorization: Bearer " \ + -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/v3/2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26?time=7d" +``` + +#### Response + +```json +{ + "issueId": "2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26", + "sharedIssueId": "8b4d0f6a1c93e57204ab8d1f6e3c9057b24da8f1093c6e5b7d20a41fc8e93b56", + "schemaId": "hAtPI0dEsq", + "sourceId": "9Zq7YAo0R", + "eventName": "Checkout Completed", + "propertyName": "revenue", + "issueType": { + "type": "PropertyTypeInconsistentWithTrackingPlan", + "PropertyTypeInconsistentWithTrackingPlan": { + "eventId": "yT2rKpQ4Xa", + "propertyId": "Bv8nLm1Zq0", + "propertyName": "revenue", + "expectedPropertyType": "float", + "actualPropertyType": "string" + } + }, + "oldestAppVersion": "8.13.1", + "newestAppVersion": "8.14.2", + "firstSeen": "2026-08-11T09:42:18.000Z", + "lastSeen": "2026-08-24T06:00:00.000Z", + "issueCount": 9871, + "eventCount": 644390, + "appVersions": { + "8.13.1": { + "appVersion": "8.13.1", + "issueCount": 7204, + "eventCount": 402118, + "lastSeen": "2026-08-23T21:00:00.000Z" + }, + "8.14.2": { + "appVersion": "8.14.2", + "issueCount": 2667, + "eventCount": 242272, + "lastSeen": "2026-08-24T06:00:00.000Z" + } + }, + "issueStatus": { + "status": { "type": "Unresolved" }, + "updatedAt": null, + "updatedBy": null + }, + "regression": false, + "branchIds": [] +} +``` + +## C — Listing event variations + +```Url +GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations +``` + +A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. Endpoints A and B tell you *that* an event is wrong; this endpoint tells you *how* it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. + +That is what makes it the debugging endpoint. Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. + +Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token — so this is the endpoint a service-account integration uses in place of endpoint B. + +`:issueId` here is a real **`issueId`** — the same kind of id endpoint B takes, *not* a `sharedIssueId`, even though this route sits directly beside the shared-id route. + +### Query parameters + +| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | +| --- | --- | --- | --- | --- | --- | +| `format` | string | Optional | `json` | `csv`, case-insensitive | Anything else — including `""` and `xml` — returns JSON. Never errors. | +| `sourceId` | string | Optional | No source filter | **One** exact `source_id` | Blank or whitespace means no filter. | +| `appVersion` | string | Optional | No version filter | **One** exact `app_version` | Blank or whitespace means no filter. | + + +**`sourceId` and `appVersion` take single values only.** The filters are strict equality, so `?sourceId=a,b` matches the literal string `"a,b"` and returns nothing. Repeating a parameter — `?sourceId=a&sourceId=b` — arrives as an array, is parsed as absent, and the filter is **silently ignored** with no error. A non-string route parameter (for example a duplicated `:issueId`) returns `400 {"error": "Invalid request"}` *before* authentication runs. + + + +**The issue's own source is not applied as a filter.** Without `?sourceId=`, you get variations of that **event name across every source in the workspace**, not just the source the issue was reported on. If you want the issue's own source, pass its `sourceId` explicitly. + + +#### Staying under the 400-row cap + +The query is capped at 400 rows, and both `app_version` and `source_id` are grouping keys — so one logical event shape yields **one row per app version per source**. An event with modest shape diversity across several versions and sources reaches the cap on cardinality alone. + +Ordering and the limit are applied in the database, before anything you could filter client-side, so the shape you care about may already have been cut from the response. Filtering after the fact does not recover it. Passing `?sourceId=` and `?appVersion=` — taking the `sourceId` from the issue itself — is the sanctioned way to stay under the cap. + +### Response + +The envelope is `{"variations": [...], "variationsTruncated": bool}`. Each row has exactly these 13 fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `eventVariationKey` | string | Identifies this shape. sha256 hex of `schemaId + sourceId + eventName + appVersion + propertyNameSignature + propertyTypeSignature`, so it changes whenever any of those change. | +| `causingIssue` | boolean | Whether this shape is one of the shapes causing the issue you asked about. | +| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally** — the pipeline sums `count / samplingRate` and rounds, so on a sampled source this is an extrapolated estimate. Treat it as an estimate when comparing against counts from your own systems. | +| `eventName` | string | The event name as observed. | +| `sourceId` | string | The Avo Source ID. | +| `schemaId` | string | Your workspace ID. | +| `appVersion` | string \| null | Nullable in the encoder, but always populated on this endpoint — a row with no app version fails to decode and is dropped. | +| `minCreatedAt` | string \| null | ISO 8601. Invalid or infinite timestamps emit `null` rather than throwing. | +| `maxCreatedAt` | string \| null | ISO 8601, same guard. | +| `eventKey` | string \| null | **Not a tracking-plan ID.** sha256 hex of `schemaId + sourceId + eventName`, computed from the *observed* event name. All variations of one observed name on one source share it. Nullable in the encoder, always populated here. | +| `sourceKey` | string \| null | **Not the same value as `sourceId`.** The composite `schemaId + "-" + sourceId`. Use `sourceId` for anything that has to match an Avo Source. Nullable in the encoder, always populated here. | +| `propertyNameSignature` | string[] | Observed property names, sorted by name. | +| `propertyTypeSignature` | string[] | Observed property types. **Strictly parallel to `propertyNameSignature`** — same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. Both are built by mapping one list of (name, type) pairs sorted by name, and an event whose types cannot be fully parsed is dropped rather than emitted with a short array. | + +#### Read `variationsTruncated`, never count rows + + +**Never compare `variations.length` to 400.**
+`variationsTruncated` is computed from the **raw** row count, but rows that fail to decode are dropped from the array you receive. So a truncated page can arrive with **399 rows and look complete**. The flag is the only reliable completeness signal. +
+ +#### Property names are raw observed names + + +**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** These come straight from the event payload; nothing in that path consults the [Tracking Plan](/data-design/start-data-design). The only mutation is privacy redaction of values shaped like data in a name position, which surfaces as the literals ``, `` and ``. If you diff these against tracking-plan property names, reconcile naming conventions first or you will report false discrepancies.

+Redaction can also map two distinct names onto the **same** placeholder, so `propertyNameSignature` is not guaranteed to be free of duplicates. In the CSV those duplicates collapse into a single column and the last type wins. + + +The window here is a fixed 24 hours, and the most recent hour is not visible at all — see [the time windows are fixed](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing) above. + +### CSV output + +`?format=csv` returns the same rows shaped for diffing: causing shapes in one section, healthy shapes in another, with one column per property name so the two halves line up column for column. Reach for it when you want to eyeball a shape difference or hand the result to a spreadsheet rather than parse it. + +The response is `text/csv; charset=utf-8`, lines joined with `\n`, **no trailing newline** and no BOM. The structure is fixed: + +1. **Line 0 is always the truncation marker**, emitted for both verdicts: `# variationsTruncated: true` or `# variationsTruncated: false`. +2. `# Variations causing the issue`, then a header line, then the causing rows. +3. `# Variations not causing the issue`, then the **same** header line again, then the remaining rows. + +Both section headers are emitted even when a section is empty, and both sections repeat an identical header line so the two halves diff column for column. Causing rows come first. + +Columns, in order: + +``` +event_variation_key, causing_issue, count, event_name, source_id, app_version, +min_created_at, max_created_at +``` + +...followed by **one column per property name**: the union of `propertyNameSignature` across all rows, deduped in first-appearance order, with the causing rows scanned first. `causing_issue` is an explicit column rendered `true` / `false`. + +Each property cell holds the **type** of that property in that row, and an empty cell when the row does not carry the property. A cell can also hold the literal `unknown`, which means the row supplied the name but no type at that position — a defensive fallback that the current ingestion path should never produce, but worth handling if you parse strictly. Date cells fall back to an empty cell rather than throwing on an invalid timestamp. + +Quoting: every cell — **including the header line** — is wrapped in double quotes, except an empty string, which stays bare. Internal `"` is doubled. A cell starting with `=`, `+`, `-`, `@`, tab, CR or LF is prefixed with `'` as a CSV injection guard. + +```csv +# variationsTruncated: false +# Variations causing the issue +"event_variation_key","causing_issue","count","event_name","source_id","app_version","min_created_at","max_created_at","currency","payment_method","revenue" +"5d2b81f0a37c94e618df05b2c7a3e9410fb86d24c503a1e79b0d4f6238ca7e15","true","1428","Checkout Completed","9Zq7YAo0R","8.14.2","2026-08-23T07:00:00.000Z","2026-08-24T06:00:00.000Z","string","string","string" +"b0f47ac125d3e896402fc7b13a5d90e648127cf3ab05d9e7261340bfc85a92d6","true","96","Checkout Completed","9Zq7YAo0R","8.13.1","2026-08-23T07:00:00.000Z","2026-08-24T05:00:00.000Z","string",,"string" +# Variations not causing the issue +"event_variation_key","causing_issue","count","event_name","source_id","app_version","min_created_at","max_created_at","currency","payment_method","revenue" +"e93c4a70b1d582f6047ae3c9128d5b0f76a2e841c30f9b57d6812ac4053e7fb9","false","94776","Checkout Completed","9Zq7YAo0R","8.14.2","2026-08-23T07:00:00.000Z","2026-08-24T06:00:00.000Z","string","string","float" +``` + +In that example the second causing row has no `payment_method` property, so its cell is bare. + +### Status codes + +| Code | Body | Condition | +| --- | --- | --- | +| `200` | JSON or CSV | Success. | +| `400` | `{"error": "Invalid request"}` | A non-string route parameter — for example a duplicated `:issueId`. Checked **before** authentication. | +| `401` | `{"message": "Authorization header missing"}`, `{"message": "Authorization header missing or invalid"}` or `{"message": "Invalid authorization"}` | Missing or invalid credential — see [authentication error bodies](#authentication-error-bodies) for which is which. | +| `403` | `{"message": "Access denied to workspace"}` | Valid Bearer credential, not a member of `:workspaceId`. | +| `404` | `{"error": "Issue not found"}` | The id is not in this workspace's rows. Note the lowercase `not found`, unlike endpoint B. | +| `500` | `{"error": "Internal Server Error"}` | Identity error, row-fetch error, connection-pool failure, or an uncaught throw. | + +This endpoint fails closed on the causing-key lookup: if that lookup errors it returns 500 rather than a 200 with every row marked non-causing. + +### Example + +#### Request + +```sh +$ curl -H "authorization: Basic " \ + -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26/variations?sourceId=9Zq7YAo0R&appVersion=8.14.2" +``` + +#### Response + +```json +{ + "variations": [ + { + "eventVariationKey": "5d2b81f0a37c94e618df05b2c7a3e9410fb86d24c503a1e79b0d4f6238ca7e15", + "causingIssue": true, + "count": 1428, + "eventName": "Checkout Completed", + "sourceId": "9Zq7YAo0R", + "schemaId": "hAtPI0dEsq", + "appVersion": "8.14.2", + "minCreatedAt": "2026-08-23T07:00:00.000Z", + "maxCreatedAt": "2026-08-24T06:00:00.000Z", + "eventKey": "a4e1c07b93d5f28601ab7c4e9d0f3b2586c1a97e4f0b3d8c25e6a1470bf9d3c8", + "sourceKey": "hAtPI0dEsq-9Zq7YAo0R", + "propertyNameSignature": ["currency", "payment_method", "revenue"], + "propertyTypeSignature": ["string", "string", "string"] + }, + { + "eventVariationKey": "e93c4a70b1d582f6047ae3c9128d5b0f76a2e841c30f9b57d6812ac4053e7fb9", + "causingIssue": false, + "count": 94776, + "eventName": "Checkout Completed", + "sourceId": "9Zq7YAo0R", + "schemaId": "hAtPI0dEsq", + "appVersion": "8.14.2", + "minCreatedAt": "2026-08-23T07:00:00.000Z", + "maxCreatedAt": "2026-08-24T06:00:00.000Z", + "eventKey": "a4e1c07b93d5f28601ab7c4e9d0f3b2586c1a97e4f0b3d8c25e6a1470bf9d3c8", + "sourceKey": "hAtPI0dEsq-9Zq7YAo0R", + "propertyNameSignature": ["currency", "payment_method", "revenue"], + "propertyTypeSignature": ["string", "string", "float"] + } + ], + "variationsTruncated": false +} +``` + +The two shapes carry the same property names and differ only in the type of `revenue` — `string` on the shape causing the issue, `float` on the healthy one. That diff, plus the `count` ratio, is what these endpoints are for. Note that `eventKey` and `sourceKey` are identical on both rows: they identify the observed event name and the source, not the shape. + +## What's next? + +Now that you can read issues over HTTP, the conceptual docs explain what you are looking at and what to do about it: + +- [Issue types in Inspector](/inspector/issue-types-in-inspector) — what each `issueType` detects, in the same language the Avo web app uses. +- [Inspector issues view](/inspector/inspector-issues-view) — the view backed by endpoint A, including issue statuses and regressions. +- [Fixing issues found in Inspector](/inspector/inspector-fix-issues) — turning a variation diff into a tracking plan or implementation change. +- [Authentication](/public-api/authentication#authenticating-with-avo-api) — creating a service account and building the `Authorization` header. diff --git a/pages/reference/public-api/overview.mdx b/pages/reference/public-api/overview.mdx index f64899685..ca219b2a1 100644 --- a/pages/reference/public-api/overview.mdx +++ b/pages/reference/public-api/overview.mdx @@ -9,3 +9,4 @@ Avo API Uses [Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_a - [Export Branch Stats](/public-api/export-branch-stats): Export your Avo branch stats as CSV for all branches in the workspace - [Create Branch](/public-api/create-branch): Create a new branch in your Avo workspace - [Import Tracking Plan](/reference/public-api/import-tracking-plan): Import a tracking plan from a CSV file into a specific branch in your Avo workspace +- [Inspector Issues](/reference/public-api/inspector-issues): Read Inspector issues and the observed event shapes behind them From b83e9981fcdf51ba22765d5bcab6f4b13dae1956 Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 09:19:56 +0000 Subject: [PATCH 02/11] Remove the issue list endpoint from the public reference The list endpoint (/inspector/issues/v5) is too heavy to expose as a public API, so it comes out of the docs entirely. The page now covers only the two id-addressed endpoints: a single issue (/inspector/issues/v3/{issueId}) and event-shape variations (/inspector/issues/{issueId}/variations). Readers arrive with an issueId, which the Avo web app exposes in the URL of an open issue. The single-issue response was previously documented as a diff against the list endpoint's field table, so that documentation moves into the single-issue section and is now self-contained: the full field table, the issueType and issueStatus unions, and the regression semantics. Two cross-cutting warnings were specific to the list endpoint and go with it: the 24h count-gated window with the three meanings of an empty array, and the silent HTTP 200 with an empty array on a database failure. Both remaining endpoints propagate a database failure as a 500, so that trap no longer exists on this page. Sections are renamed off the A/B/C labels now that only two endpoints remain, and every in-page anchor was rewired to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- .../reference/public-api/inspector-issues.mdx | 314 +++++------------- pages/reference/public-api/overview.mdx | 2 +- 2 files changed, 85 insertions(+), 231 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 33d06bc3c..2569557d4 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -4,27 +4,26 @@ import { Callout } from 'nextra/components'; _Read Inspector issues and observed event shapes over HTTP_ -Three GET endpoints expose Inspector data outside the Avo web app: a list of issues, a single issue, and the observed event shapes ("variations") behind an issue. They are documented together because they share a base URL and a workspace-scoping model — but **not** an authentication model, and the differences between them cause most broken integrations. +Two GET endpoints expose Inspector data outside the Avo web app: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId` you already hold. They are documented together because they share a base URL and a workspace-scoping model — but **not** an authentication model, and that difference causes most broken integrations. This page is written for someone wiring these endpoints into a script, a CI check, or an agent tool. The response body is your only view of the data, so every field, fallback and silent behavior is spelled out below. -Base URL for all three: `https://api.avo.app` +Base URL for both: `https://api.avo.app` ## Endpoints -| # | Method and path | Returns | Reach for it when | -| --- | --- | --- | --- | -| A | `GET /workspaces/:workspaceId/inspector/issues/v5` | The issue **list** — `{"issues": [...]}` | You want the issues currently counting in a workspace. This is the endpoint most integrations need, and the only one that hands you `issueId` values to pass to the other two. | -| B | `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You already have one `issueId` and need per-app-version counts, or a window other than 24 hours. Accepts a Firebase ID token only. | -| C | `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You need the payloads themselves — which property names and types were actually sent — so you can diff the shape causing the issue against the healthy one. | +| Method and path | Returns | Reach for it when | +| --- | --- | --- | +| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You need per-app-version counts for one issue, or a window other than 24 hours. Accepts a Firebase ID token only. | +| `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You need the payloads themselves — which property names and types were actually sent — so you can diff the shape causing the issue against the healthy one. | `:workspaceId` is the ID of your workspace. You'll find it in the URL of your Avo tab after `/schemas/`. It is also returned as `schemaId` on every response object. -Endpoint A is the entry point: it is the only endpoint that does not need an id up front, and endpoints B and C both take an `issueId` from its response. There is no endpoint that searches for an issue by event name. +Both endpoints take an `issueId`. You'll find an issue's id in the Avo web app URL when you open that issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. ### Watch the path: `/issues/:issueId` is a different resource -There is a fourth route in this path space that is easy to hit by accident: +There is a third route in this path space that is easy to hit by accident: ```Url GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId @@ -32,38 +31,33 @@ GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId **`/issues/:issueId` without `/v3/` resolves a `sharedIssueId`, not an `issueId`.**
-Despite the path segment name, this route looks up `shared_issue_id`. Because a shared issue spans sources, it returns a **bare JSON array** — one endpoint B object per source — rather than a single object. Query parameters are dropped on this route before they reach the handler, so anything you append is silently ignored. +Despite the path segment name, this route looks up `shared_issue_id`. Because a shared issue spans sources, it returns a **bare JSON array** — one single-issue object per source — rather than a single object. Query parameters are dropped on this route before they reach the handler, so anything you append is silently ignored.
-Endpoint C sits directly beside that route in the path space, but takes a real `issueId` — the same kind of id endpoint B takes. So `/issues/:id` wants a `sharedIssueId` while `/issues/:id/variations` wants an `issueId`. Passing the wrong kind of id to either returns 404 rather than an error that explains itself. +The variations endpoint sits directly beside that route in the path space, but takes a real `issueId` — the same kind of id the single-issue endpoint takes. So `/issues/:id` wants a `sharedIssueId` while `/issues/:id/variations` wants an `issueId`. Passing the wrong kind of id to either returns 404 rather than an error that explains itself. ## Authentication -Authentication is **not uniform across these endpoints**. This is the single most common cause of a working list call sitting next to a 401 on the detail call. +Authentication is **not uniform across these endpoints**. This is the single most common cause of a working variations call sitting next to a 401 on the single-issue call. -| Credential | A `/issues/v5` | B `/issues/v3/:issueId` | C `/variations` | -| --- | --- | --- | --- | -| Service account Basic (`Authorization: Basic base64(name:secret)`) | ✅ | ❌ 401 | ✅ | -| Avo OAuth JWT (`Authorization: Bearer ...`) | ✅ | ❌ 401 | ✅ | -| Firebase ID token (`Authorization: Bearer ...`) | ✅ | ✅ | ✅ | +| Credential | `/issues/v3/:issueId` | `/variations` | +| --- | --- | --- | +| Service account Basic (`Authorization: Basic base64(name:secret)`) | ❌ 401 | ✅ | +| Avo OAuth JWT (`Authorization: Bearer ...`) | ❌ 401 | ✅ | +| Firebase ID token (`Authorization: Bearer ...`) | ✅ | ✅ | **The single-issue endpoint accepts a Firebase ID token only.**
-`GET /inspector/issues/v3/:issueId` runs on an older auth stack that requires a `Bearer ` prefix and verifies the token as a Firebase ID token. A service account Basic credential and an Avo OAuth JWT are both **rejected with 401** — including a service account that is correctly registered in the workspace. If you are integrating with a service account, there is no supported way to call endpoint B. +`GET /inspector/issues/v3/:issueId` runs on an older auth stack that requires a `Bearer ` prefix and verifies the token as a Firebase ID token. A service account Basic credential and an Avo OAuth JWT are both **rejected with 401** — including a service account that is correctly registered in the workspace. If you are integrating with a service account, there is no supported way to call it.
-We recommend the following path for any service-account or OAuth integration: - -1. List issues with **endpoint A** (`/issues/v5`) — it carries every field endpoint B carries, except that `appVersions` is an array of version names rather than per-version counts. -2. For the shapes behind a specific issue, call **endpoint C** (`/variations`) with the `issueId` from step 1. - -Neither A nor C requires an OAuth scope, and neither requires a particular workspace role — any workspace member passes. +We recommend planning your integration around that split: a service-account or OAuth credential can call `/variations` with an `issueId` it already holds, and that is the endpoint to build on. The variations endpoint requires no OAuth scope and no particular workspace role — any workspace member passes. See [authorization header](/public-api/authentication#authenticating-with-avo-api) for how to build the Basic credential from a service account name and secret. The `Basic ` scheme is matched **case-sensitively**, so a lowercase `basic ` is not recognized as a service-account credential — it is treated as a malformed Bearer token and rejected with the message below. ### Authentication error bodies -Endpoints A and C share one authenticator, so they return the same four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. +The variations endpoint runs on the shared Avo API authenticator, which returns these four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. | Code | Body | Condition | | --- | --- | --- | @@ -72,81 +66,63 @@ Endpoints A and C share one authenticator, so they return the same four bodies. | `401` | `{"message": "Invalid authorization"}` | Any Basic failure: bad secret, unknown service account, or a service account not registered in this workspace. | | `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | -A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. Endpoint B is on a different stack and answers every auth failure with `401 {"error": "Unauthorized"}`. +A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. The single-issue endpoint is on a different stack and answers every auth failure with `401 {"error": "Unauthorized"}`. ### Workspace scoping Every query filters on `schema_id`, so a credential can only ever see its own workspace's rows. That produces two different failures that are easy to confuse: -- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. On endpoint B, a non-member gets **401 `{"error": "Unauthorized"}`**. +- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. On the single-issue endpoint, a non-member gets **401 `{"error": "Unauthorized"}`**. - **404** — the credential is valid *and* scoped to the right workspace, but the requested id isn't in that workspace's rows. Because the lookup is workspace-scoped (`schema_id = $1 AND issue_id = $2`), an id belonging to a different workspace simply doesn't match and returns 404 rather than revealing that the id exists elsewhere. So a 403 means "wrong workspace credential" and a 404 means "right credential, id not here" — including the case where the id is real but lives in someone else's workspace. Super-admin credentials bypass both checks. ### Rate limits -There is no rate limit on any of these three endpoints. +There is no rate limit on either of these endpoints. ## Your first call -Once you have a credential, listing issues is a single request. Everything else on this page is a refinement of it. +Once you have a credential and an `issueId`, the event shapes behind that issue are a single request: ```sh -$ curl --compressed \ - -H "authorization: Basic " \ - -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/v5" +$ curl -H "authorization: Basic " \ + -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations" ``` -That returns `{"issues": [...]}` for the workspace's unresolved issues. From there you can: +That returns `{"variations": [...], "variationsTruncated": false}` — every shape that event was seen in over the last 24 hours, each flagged with whether it is one of the shapes causing the issue. From there you can: -- Narrow the list with `status` and `appVersions` — see [endpoint A's query parameters](#query-parameters). -- Take any `issues[].issueId` and call [endpoint C](#c--listing-event-variations) to see the event shapes behind it. +- Narrow the response to one source and one app version, or switch it to CSV — see [listing event variations](#listing-event-variations). +- Read the issue's own counts broken down per app version with [the single-issue endpoint](#retrieving-a-single-issue), if you hold a Firebase ID token. Now that you have a working call, the sections below cover what the response does *not* tell you. ## Before you integrate -Seven behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Five of them cut across endpoints and are covered here: - -1. [The list is a 24-hour, count-gated view](#the-list-is-a-24-hour-count-gated-view--not-all-issues) — and an empty array has three different meanings. -2. [The time windows are fixed, and the freshest hour is missing](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing). -3. [`eventCount` is not "events affected by this issue"](#eventcount-is-not-events-affected-by-this-issue). -4. [`issueId` is a snapshot handle, not a durable key](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity). -5. [No response tells you which event variant was matched](#no-variant-attribution). - -Two more are specific to endpoint C and are covered in its own section: [`variationsTruncated` is the only reliable completeness signal](#read-variationstruncated-never-count-rows), and [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. +Six behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Four of them cut across both endpoints and are covered here: -### The list is a 24-hour, count-gated view — not "all issues" +1. [The time windows are fixed, and the freshest hour is missing](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing). +2. [`eventCount` is not "events affected by this issue"](#eventcount-is-not-events-affected-by-this-issue). +3. [`issueId` is a snapshot handle, not a durable key](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity). +4. [No response tells you which event variant was matched](#no-variant-attribution). - -**An empty `issues` array has three legitimate meanings, and you cannot tell them apart from the response.**
-For every status except `Resolved`, an issue is returned only if it was **last seen within 30 days** *and* accumulated **at least one violating occurrence in the last 24 hours**. So `{"issues": []}` may mean:

-1. The workspace is genuinely clean.
-2. **The query failed.** On a Postgres query or connection failure the endpoint returns **HTTP 200 with `{"issues": []}`** — deliberately fail-closed, not a 5xx. A dead database replica is observably identical to a clean workspace.
-3. There are many open issues, none of which fired in the last 24 hours. This is the normal state of a healthy workspace between releases.

-Do not build an alert on "the array is empty" and do not treat an empty array as proof of health. If you need a durable inventory of open issues, poll on a schedule and keep your own record rather than trusting a single response. -
- -Two further consequences of the count gate: - -- The issue-count join is chained through the event-count join on `app_version`, so the issue must have fired in the last 24 hours *in an app version that also has event counts in the same window*. Otherwise its count is 0 and the issue is dropped from the response. -- For `status=Resolved`, **both** the 30-day gate and the 24-hour count gate are lifted. +Two more are specific to the variations endpoint and are covered in its own section: [`variationsTruncated` is the only reliable completeness signal](#read-variationstruncated-never-count-rows), and [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. ### The time windows are fixed, and the freshest hour is missing -Endpoint A counts over 24 hours and endpoint C looks back 24 hours. Neither window is configurable — both are literals in the query, with no parameter to widen or shift them. Endpoint B is the only one that takes a window, via its `time` parameter. +The variations endpoint looks back a fixed 24 hours. That window is a literal in the query, with no parameter to widen or shift it. The single-issue endpoint is the one that takes a window, via its `time` parameter. **Expect roughly an hour of lag, and do not use these endpoints to verify a deploy you just shipped.**
-Both endpoints read continuous aggregates refreshed on a ten-minute schedule with a one-hour end offset. On endpoint A that means roughly an hour of lag on the freshest counts. On endpoint C the aggregate is materialized-only, so **the most recent hour is not visible at all** — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) rather than these endpoints. +The counts are read from continuous aggregates refreshed on a ten-minute schedule with a one-hour end offset, which puts roughly an hour of lag on the freshest numbers. On the variations endpoint the aggregate is materialized-only, so **the most recent hour is not visible at all** — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) rather than these endpoints.
-Looking further back is not an option either: on endpoint C both the aggregate and the raw table drop data after 48 hours, so the 24-hour window is always fully covered and there is nothing older to read. +Looking further back is not an option on the variations endpoint either: both the aggregate and the raw table drop data after 48 hours, so the 24-hour window is always fully covered and there is nothing older to read. ### `eventCount` is not "events affected by this issue" -**`eventCount` is the total 24-hour volume of that event on that source — every shape, healthy ones included.** `issueCount` is the per-issue figure: occurrences in the last 24 hours that actually violated.

+**`eventCount` is the total volume of that event on that source in the window — every shape, healthy ones included.** `issueCount` is the per-issue figure: occurrences in the same window that actually violated.

The number worth reporting is the ratio. `issueCount: 1428` against `eventCount: 96204` is a 1.5% violation rate on a high-volume event; reading `eventCount` as "affected events" overstates the blast radius by two orders of magnitude.
@@ -168,44 +144,31 @@ That stability only goes so far, though. `sharedIssueId` is insulated from **new **Nothing in any response — JSON or CSV — tells you which event variant Inspector matched against.** There is no variant field in any of these payloads, no variant column in the underlying tables, and variant is not an input to either id hash. The tracking-plan model reaches the matcher already flattened, so variant identity is erased before validation and never reaches the issue row. It cannot be recovered from the response or from the id. If your tracking plan leans on [variants](/data-design/avo-tracking-plan/event-variants), expect to reconcile variant identity yourself. -## A — Listing issues +## Retrieving a single issue ```Url -GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v5 +GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId ``` -Returns every issue currently counting in the workspace, one row per event-or-property problem **per source**. This is the same data behind the [Inspector issues view](/inspector/inspector-issues-view), and the endpoint to start from: it is the only one you can call without already holding an id. +Returns one issue with its counts **broken down per app version**, over a window you choose. This is the endpoint to reach for when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. -Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token. +`:issueId` is an `issueId`, not a `sharedIssueId` — see [watch the path](#watch-the-path-issuesissueid-is-a-different-resource) above. -**The response is always gzipped.** `Content-Encoding: gzip` is set unconditionally, regardless of what you send in `Accept-Encoding`. Pass `--compressed` to curl, or decompress explicitly in your HTTP client. This is unique to endpoint A — neither B nor C sets `Content-Encoding`. + +**Firebase ID token only.** Service account Basic and Avo OAuth JWT are both rejected with 401 on this endpoint. See [Authentication](#authentication) for what a service-account integration can call instead. + ### Query parameters -The handler reads exactly these two, plus `:workspaceId` from the path. - | Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | | --- | --- | --- | --- | --- | --- | -| `status` | string | Optional | `Unresolved` | `unresolved`, `ignored`, `resolved` — case-insensitive | **Silently falls back to `Unresolved`.** No 400. | -| `appVersions` | comma-separated string | Optional | No version filter | Any strings, split on `,` | An empty string is treated as absent. Unknown versions match nothing, so the issue is excluded. No 400. | - -The three `status` values map to the statuses you set in the Avo web app. Note the naming shift across the three layers, which is the usual source of a silently-empty response: - -| Avo web app label | `status` parameter value | `issueStatus.status.type` in the response | -| --- | --- | --- | -| Unresolved | `unresolved` | `Unresolved` | -| **Ignore** | `ignored` | `Ignored` | -| Resolved | `resolved` | `Resolved` | - -The parameter is lower-cased before matching, so `ignored`, `Ignored` and `IGNORED` are all accepted — but `ignore` is not, and falls back to `Unresolved` without an error. `status=unresolved` also returns issues that have never had a status set. See [issue status](/inspector/inspector-issues-view#issue-status) for what each one means. +| `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | - -**There is no `sourceId`, `eventName`, `category`, `tag`, saved view, time range, `sort`, `limit`, `offset`, `cursor` or `format` parameter on this endpoint,** and the query has no `ORDER BY`, `LIMIT` or `OFFSET`. All source, event, category and tag filtering, and all sorting, in the [Inspector issues view](/inspector/inspector-issues-view) happens client-side after the full response is fetched. Plan to filter and sort in your own code. - +`time` also selects the underlying rollup: `24h` reads the eight-hour aggregates, anything else reads the daily aggregate tables. The value is regex-sanitized before use. There is no `format`, no filtering and no pagination on this endpoint. ### Response -The envelope is exactly `{"issues": [...]}` — no `total`, no `nextCursor`, no sibling fields. +A **bare object**, not wrapped in an envelope, and **not** gzipped. | Field | Type | Notes | | --- | --- | --- | @@ -219,42 +182,15 @@ The envelope is exactly `{"issues": [...]}` — no `total`, no `nextCursor`, no | `oldestAppVersion` | string | | | `newestAppVersion` | string | | | `firstSeen` | string (ISO 8601) | Earliest first-seen for this issue row. | -| `lastSeen` | string (ISO 8601) | Max last-seen across the 24-hour buckets, falling back to the last-seen day. | -| `issueCount` | number | Occurrences that **violated**, last 24 hours only. | -| `eventCount` | number | **Total** occurrences of that event on that source in the last 24 hours, all shapes including healthy ones. | -| `appVersions` | string[] | Distinct versions seen in the 24-hour window. Names only — no per-version stats on this endpoint. Can be `[]` under `status=Resolved`, where the count gate is lifted and the issue is returned even with no counts in the window. | +| `lastSeen` | string (ISO 8601) | Max last-seen across versions, falling back to the last-seen day. | +| `issueCount` | number | Occurrences that **violated**, summed across versions. | +| `eventCount` | number | **Total** occurrences of that event on that source, all shapes including healthy ones, summed across versions. | +| `appVersions` | object | A **dictionary keyed by version string, not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | | `issueStatus` | object | `{status, updatedAt: string \| null, updatedBy: string \| null}` | | `regression` | boolean | Always present. `true` when this issue had been marked **Resolved** and was then observed again — see below. | | `branchIds` | string[] | Always present; `[]` when the issue is not linked to any branch. | -#### `regression` - -`regression` is set to `true` when an issue a user had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: - -| `validateIn` | Regresses when the newly observed variation is | -| --- | --- | -| `CurrentAppVersion(v)` | on app version **≥ v** | -| `CustomAppVersion(v)` | on app version **≥ v** | -| `NextAppVersion(v)` | on app version **strictly > v** | -| `Date(t)` | seen **after** `t` | -| `Never` | never — the issue is not reopened and never flagged | - -Two things to code around: - -- **`Ignored` does not produce a regression.** An ignored issue that resurfaces is also moved back to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. -- **The flag is cleared the moment anyone sets the status manually again**, to any value. A newly created issue is never a regression. - -Read `regression` together with `issueStatus.status`: the Avo web app only surfaces it while the status is `Unresolved`, which is the only state it is meaningful in. - -#### `issueStatus.status` - -```json -{ "type": "Unresolved" } -{ "type": "Ignored", "validateIn": { "type": "NextAppVersion", "appVersion": "8.15.0" } } -{ "type": "Resolved", "validateIn": { "type": "Never" } } -``` - -`validateIn` is one of `{"type":"CurrentAppVersion","appVersion":string}`, `{"type":"NextAppVersion","appVersion":string}`, `{"type":"CustomAppVersion","appVersion":string}`, `{"type":"Date","date":ISO 8601}` or `{"type":"Never"}`. +Top-level `issueCount` and `eventCount` are the sums across versions; top-level `lastSeen` is the max across versions, falling back to the last-seen day. #### `issueType` @@ -271,124 +207,44 @@ A tagged union: `type` plus a payload key. The concepts behind each type are doc **Casing inconsistency to code around:** every payload key is camelCase *except* `PropertyTypeInconsistentWithTrackingPlan`, whose payload key repeats the PascalCase type name. `eventId` inside that payload is nullable; the other payloads' ids are not. -### Status codes - -| Code | Condition | -| --- | --- | -| `200` | Success (gzipped). | -| `200` with `{"issues": []}` | Also returned on a **Postgres query or connection failure** — fail-closed by design. Not a 5xx. | -| `200`, partially populated | Per-row decode failures are swallowed: a malformed row is dropped from an otherwise successful response, with no marker in the body. | -| `401` | No `Authorization` header, invalid bearer token, bad Basic secret, or a service account not registered in this workspace. See [authentication error bodies](#authentication-error-bodies). | -| `403` | Authenticated, but not an ACL member of `:workspaceId` — including an unknown `:workspaceId`. Body `{"message": "Access denied to workspace"}`. | -| `500` | Body `{"error": "Internal Server Error"}`. A throw after authentication, for example an invalid date reaching the encoder. | - -There is **no 400** for any malformed parameter, and **no 404** on this endpoint. - -### Example - -#### Request - -```sh -$ curl --compressed \ - -H "authorization: Basic " \ - -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/v5?status=unresolved&appVersions=8.14.2,8.13.1" -``` - -#### Response +#### `issueStatus.status` ```json -{ - "issues": [ - { - "issueId": "2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26", - "sharedIssueId": "8b4d0f6a1c93e57204ab8d1f6e3c9057b24da8f1093c6e5b7d20a41fc8e93b56", - "schemaId": "hAtPI0dEsq", - "sourceId": "9Zq7YAo0R", - "eventName": "Checkout Completed", - "propertyName": "revenue", - "issueType": { - "type": "PropertyTypeInconsistentWithTrackingPlan", - "PropertyTypeInconsistentWithTrackingPlan": { - "eventId": "yT2rKpQ4Xa", - "propertyId": "Bv8nLm1Zq0", - "propertyName": "revenue", - "expectedPropertyType": "float", - "actualPropertyType": "string" - } - }, - "oldestAppVersion": "8.13.1", - "newestAppVersion": "8.14.2", - "firstSeen": "2026-08-11T09:42:18.000Z", - "lastSeen": "2026-08-24T06:00:00.000Z", - "issueCount": 1428, - "eventCount": 96204, - "appVersions": ["8.13.1", "8.14.2"], - "issueStatus": { - "status": { "type": "Unresolved" }, - "updatedAt": null, - "updatedBy": null - }, - "regression": false, - "branchIds": [] - }, - { - "issueId": "c07a5f39b1d84e26af0c93b7512de6a8409fb17c3d6528eab94017f2c85d3b60", - "sharedIssueId": "e51b7d02a94c36f8017be2d5c8390a4f62d17bc03e9584a1f70d2c6b83459e17", - "schemaId": "hAtPI0dEsq", - "sourceId": "kR4vXn8Tb", - "eventName": "Subscription Renewal Reminder Dismissed", - "propertyName": null, - "issueType": { "type": "EventNotInTrackingPlan" }, - "oldestAppVersion": "8.14.2", - "newestAppVersion": "8.14.2", - "firstSeen": "2026-08-22T14:07:55.000Z", - "lastSeen": "2026-08-24T06:00:00.000Z", - "issueCount": 3106, - "eventCount": 3106, - "appVersions": ["8.14.2"], - "issueStatus": { - "status": { "type": "Unresolved" }, - "updatedAt": null, - "updatedBy": null - }, - "regression": false, - "branchIds": [] - } - ] -} -``` - -## B — Retrieving a single issue - -```Url -GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId +{ "type": "Unresolved" } +{ "type": "Ignored", "validateIn": { "type": "NextAppVersion", "appVersion": "8.15.0" } } +{ "type": "Resolved", "validateIn": { "type": "Never" } } ``` -Returns one issue with its counts **broken down per app version**, over a window you choose. Endpoint A gives you app version *names* only, so this is the endpoint to reach for when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. +`validateIn` is one of `{"type":"CurrentAppVersion","appVersion":string}`, `{"type":"NextAppVersion","appVersion":string}`, `{"type":"CustomAppVersion","appVersion":string}`, `{"type":"Date","date":ISO 8601}` or `{"type":"Never"}`. -`:issueId` is an `issueId` — the value from `issues[].issueId` on endpoint A, not a `sharedIssueId`. +Note the naming shift between the label you set in the Avo web app and the value you read back: - -**Firebase ID token only.** Service account Basic and Avo OAuth JWT are both rejected with 401 on this endpoint. See [Authentication](#authentication) for the service-account path (list with A, shapes with C). - +| Avo web app label | `issueStatus.status.type` | +| --- | --- | +| Unresolved | `Unresolved` | +| **Ignore** | `Ignored` | +| Resolved | `Resolved` | -### Query parameters +An issue that has never had a status set reads as `Unresolved`. See [issue status](/inspector/inspector-issues-view#issue-status) for what each one means. -| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | -| --- | --- | --- | --- | --- | --- | -| `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | +#### `regression` -`time` also selects the underlying rollup: `24h` reads the eight-hour aggregates, anything else reads the daily aggregate tables. The value is regex-sanitized before use. There is no `format`, no filtering and no pagination on this endpoint. +`regression` is set to `true` when an issue a user had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: -### Response +| `validateIn` | Regresses when the newly observed variation is | +| --- | --- | +| `CurrentAppVersion(v)` | on app version **≥ v** | +| `CustomAppVersion(v)` | on app version **≥ v** | +| `NextAppVersion(v)` | on app version **strictly > v** | +| `Date(t)` | seen **after** `t` | +| `Never` | never — the issue is not reopened and never flagged | -A **bare object**, not wrapped in an envelope, and **not** gzipped. Field names match endpoint A with one difference: +Two things to code around: -| Field | Type | Notes | -| --- | --- | --- | -| `appVersions` | object | A dictionary keyed by version string, **not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | +- **`Ignored` does not produce a regression.** An ignored issue that resurfaces is also moved back to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. +- **The flag is cleared the moment anyone sets the status manually again**, to any value. A newly created issue is never a regression. -Top-level `issueCount` and `eventCount` are the sums across versions; top-level `lastSeen` is the max across versions, falling back to the last-seen day. All other fields carry the same types and nullability as on endpoint A. +Read `regression` together with `issueStatus.status`: the Avo web app only surfaces it while the status is `Unresolved`, which is the only state it is meaningful in. ### Status codes @@ -399,8 +255,6 @@ Top-level `issueCount` and `eventCount` are the sums across versions; top-level | `404` | `{"error": "Issue Not found"}` | Zero rows for this workspace and id. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | | `500` | `{"error": "Internal Server Error"}` | Database error. | -Note the asymmetry with endpoint A: endpoint B propagates a database failure as a 500, while endpoint A swallows the same failure into a 200 with an empty array. If you are health-checking Inspector, B is the endpoint that tells you the truth — but only a Firebase ID token can call it. - There is no 400 on this endpoint. ### Example @@ -462,19 +316,19 @@ $ curl -H "authorization: Bearer " \ } ``` -## C — Listing event variations +## Listing event variations ```Url GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations ``` -A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. Endpoints A and B tell you *that* an event is wrong; this endpoint tells you *how* it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. +A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. The single-issue endpoint tells you *that* an event is wrong and how often; this endpoint tells you *how* it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. That is what makes it the debugging endpoint. Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. -Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token — so this is the endpoint a service-account integration uses in place of endpoint B. +Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token — so this is the endpoint a service-account integration can build on. -`:issueId` here is a real **`issueId`** — the same kind of id endpoint B takes, *not* a `sharedIssueId`, even though this route sits directly beside the shared-id route. +`:issueId` here is a real **`issueId`** — the same kind of id the single-issue endpoint takes, *not* a `sharedIssueId`, even though this route sits directly beside the shared-id route. ### Query parameters @@ -580,7 +434,7 @@ In that example the second causing row has no `payment_method` property, so its | `400` | `{"error": "Invalid request"}` | A non-string route parameter — for example a duplicated `:issueId`. Checked **before** authentication. | | `401` | `{"message": "Authorization header missing"}`, `{"message": "Authorization header missing or invalid"}` or `{"message": "Invalid authorization"}` | Missing or invalid credential — see [authentication error bodies](#authentication-error-bodies) for which is which. | | `403` | `{"message": "Access denied to workspace"}` | Valid Bearer credential, not a member of `:workspaceId`. | -| `404` | `{"error": "Issue not found"}` | The id is not in this workspace's rows. Note the lowercase `not found`, unlike endpoint B. | +| `404` | `{"error": "Issue not found"}` | The id is not in this workspace's rows. Note the lowercase `not found`, unlike the single-issue endpoint's `Issue Not found`. | | `500` | `{"error": "Internal Server Error"}` | Identity error, row-fetch error, connection-pool failure, or an uncaught throw. | This endpoint fails closed on the causing-key lookup: if that lookup errors it returns 500 rather than a 200 with every row marked non-causing. @@ -641,6 +495,6 @@ The two shapes carry the same property names and differ only in the type of `rev Now that you can read issues over HTTP, the conceptual docs explain what you are looking at and what to do about it: - [Issue types in Inspector](/inspector/issue-types-in-inspector) — what each `issueType` detects, in the same language the Avo web app uses. -- [Inspector issues view](/inspector/inspector-issues-view) — the view backed by endpoint A, including issue statuses and regressions. +- [Inspector issues view](/inspector/inspector-issues-view) — the same issues in the Avo web app, including issue statuses and regressions. - [Fixing issues found in Inspector](/inspector/inspector-fix-issues) — turning a variation diff into a tracking plan or implementation change. - [Authentication](/public-api/authentication#authenticating-with-avo-api) — creating a service account and building the `Authorization` header. diff --git a/pages/reference/public-api/overview.mdx b/pages/reference/public-api/overview.mdx index ca219b2a1..4241463cc 100644 --- a/pages/reference/public-api/overview.mdx +++ b/pages/reference/public-api/overview.mdx @@ -9,4 +9,4 @@ Avo API Uses [Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_a - [Export Branch Stats](/public-api/export-branch-stats): Export your Avo branch stats as CSV for all branches in the workspace - [Create Branch](/public-api/create-branch): Create a new branch in your Avo workspace - [Import Tracking Plan](/reference/public-api/import-tracking-plan): Import a tracking plan from a CSV file into a specific branch in your Avo workspace -- [Inspector Issues](/reference/public-api/inspector-issues): Read Inspector issues and the observed event shapes behind them +- [Inspector Issues](/reference/public-api/inspector-issues): Read a single Inspector issue by ID and the observed event shapes behind it From 8027a4fd0c77af54f960131946d5a099595fc99c Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 10:20:49 +0000 Subject: [PATCH 03/11] Drop the shared-id route from the reference, pin the path to /v3/ The /inspector/issues/{issueId} route (without /v3/) resolves a sharedIssueId rather than an issueId. Rather than document it as a neighbouring resource, the page now simply states the single-issue path as /inspector/issues/v3/{issueId} and tells readers to use it exactly as written. Removes the "Watch the path" section and rewords the two cross references that pointed at it. The sharedIssueId response field stays documented, since it is still returned in the body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/inspector-issues.mdx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 2569557d4..f90cc796e 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -21,21 +21,10 @@ Base URL for both: `https://api.avo.app` Both endpoints take an `issueId`. You'll find an issue's id in the Avo web app URL when you open that issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. -### Watch the path: `/issues/:issueId` is a different resource - -There is a third route in this path space that is easy to hit by accident: - -```Url -GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId -``` - -**`/issues/:issueId` without `/v3/` resolves a `sharedIssueId`, not an `issueId`.**
-Despite the path segment name, this route looks up `shared_issue_id`. Because a shared issue spans sources, it returns a **bare JSON array** — one single-issue object per source — rather than a single object. Query parameters are dropped on this route before they reach the handler, so anything you append is silently ignored. +**Use the `/v3/` path exactly as written.** The single-issue endpoint is `/inspector/issues/v3/:issueId`. Dropping the `/v3/` segment does not reach this endpoint.
-The variations endpoint sits directly beside that route in the path space, but takes a real `issueId` — the same kind of id the single-issue endpoint takes. So `/issues/:id` wants a `sharedIssueId` while `/issues/:id/variations` wants an `issueId`. Passing the wrong kind of id to either returns 404 rather than an error that explains itself. - ## Authentication Authentication is **not uniform across these endpoints**. This is the single most common cause of a working variations call sitting next to a 401 on the single-issue call. @@ -152,7 +141,7 @@ GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId Returns one issue with its counts **broken down per app version**, over a window you choose. This is the endpoint to reach for when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. -`:issueId` is an `issueId`, not a `sharedIssueId` — see [watch the path](#watch-the-path-issuesissueid-is-a-different-resource) above. +`:issueId` is the issue's own id, the value returned as `issueId` in the response below. **Firebase ID token only.** Service account Basic and Avo OAuth JWT are both rejected with 401 on this endpoint. See [Authentication](#authentication) for what a service-account integration can call instead. @@ -328,7 +317,7 @@ That is what makes it the debugging endpoint. Put the causing shape next to the Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token — so this is the endpoint a service-account integration can build on. -`:issueId` here is a real **`issueId`** — the same kind of id the single-issue endpoint takes, *not* a `sharedIssueId`, even though this route sits directly beside the shared-id route. +`:issueId` here is the same kind of id the single-issue endpoint takes. ### Query parameters From f236fffadfc70a0e675c01c326b1aecfadffc25a Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 10:23:43 +0000 Subject: [PATCH 04/11] Document uniform auth across both Inspector issue endpoints Both endpoints now accept service account Basic, Avo OAuth JWT and a Firebase ID token. Removes the credential matrix, the "Firebase ID token only" warnings on the single-issue endpoint, and the framing that steered service-account integrations away from it. The single-issue endpoint now shares the Avo API authenticator, so its auth failures use the shared {"message": ...} bodies and a non-member gets 403 rather than the previous 401 {"error": "Unauthorized"}. Depends on the auth change shipping first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- .../reference/public-api/inspector-issues.mdx | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index f90cc796e..4becbfd68 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -4,7 +4,7 @@ import { Callout } from 'nextra/components'; _Read Inspector issues and observed event shapes over HTTP_ -Two GET endpoints expose Inspector data outside the Avo web app: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId` you already hold. They are documented together because they share a base URL and a workspace-scoping model — but **not** an authentication model, and that difference causes most broken integrations. +Two GET endpoints expose Inspector data outside the Avo web app: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId` you already hold, and both share a base URL, an authentication model and a workspace-scoping model. This page is written for someone wiring these endpoints into a script, a CI check, or an agent tool. The response body is your only view of the data, so every field, fallback and silent behavior is spelled out below. @@ -14,7 +14,7 @@ Base URL for both: `https://api.avo.app` | Method and path | Returns | Reach for it when | | --- | --- | --- | -| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You need per-app-version counts for one issue, or a window other than 24 hours. Accepts a Firebase ID token only. | +| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You need per-app-version counts for one issue, or a window other than 24 hours. | | `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You need the payloads themselves — which property names and types were actually sent — so you can diff the shape causing the issue against the healthy one. | `:workspaceId` is the ID of your workspace. You'll find it in the URL of your Avo tab after `/schemas/`. It is also returned as `schemaId` on every response object. @@ -27,26 +27,19 @@ Both endpoints take an `issueId`. You'll find an issue's id in the Avo web app U ## Authentication -Authentication is **not uniform across these endpoints**. This is the single most common cause of a working variations call sitting next to a 401 on the single-issue call. +Both endpoints accept the same three credentials: -| Credential | `/issues/v3/:issueId` | `/variations` | -| --- | --- | --- | -| Service account Basic (`Authorization: Basic base64(name:secret)`) | ❌ 401 | ✅ | -| Avo OAuth JWT (`Authorization: Bearer ...`) | ❌ 401 | ✅ | -| Firebase ID token (`Authorization: Bearer ...`) | ✅ | ✅ | +- **Service account Basic** — `Authorization: Basic base64(name:secret)` +- **Avo OAuth JWT** — `Authorization: Bearer ...` +- **Firebase ID token** — `Authorization: Bearer ...` - -**The single-issue endpoint accepts a Firebase ID token only.**
-`GET /inspector/issues/v3/:issueId` runs on an older auth stack that requires a `Bearer ` prefix and verifies the token as a Firebase ID token. A service account Basic credential and an Avo OAuth JWT are both **rejected with 401** — including a service account that is correctly registered in the workspace. If you are integrating with a service account, there is no supported way to call it. -
- -We recommend planning your integration around that split: a service-account or OAuth credential can call `/variations` with an `issueId` it already holds, and that is the endpoint to build on. The variations endpoint requires no OAuth scope and no particular workspace role — any workspace member passes. +Neither endpoint requires an OAuth scope, and neither requires a particular workspace role — any workspace member passes. See [authorization header](/public-api/authentication#authenticating-with-avo-api) for how to build the Basic credential from a service account name and secret. The `Basic ` scheme is matched **case-sensitively**, so a lowercase `basic ` is not recognized as a service-account credential — it is treated as a malformed Bearer token and rejected with the message below. ### Authentication error bodies -The variations endpoint runs on the shared Avo API authenticator, which returns these four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. +Both endpoints run on the shared Avo API authenticator, which returns these four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. | Code | Body | Condition | | --- | --- | --- | @@ -55,13 +48,13 @@ The variations endpoint runs on the shared Avo API authenticator, which returns | `401` | `{"message": "Invalid authorization"}` | Any Basic failure: bad secret, unknown service account, or a service account not registered in this workspace. | | `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | -A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. The single-issue endpoint is on a different stack and answers every auth failure with `401 {"error": "Unauthorized"}`. +A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. ### Workspace scoping Every query filters on `schema_id`, so a credential can only ever see its own workspace's rows. That produces two different failures that are easy to confuse: -- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. On the single-issue endpoint, a non-member gets **401 `{"error": "Unauthorized"}`**. +- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. - **404** — the credential is valid *and* scoped to the right workspace, but the requested id isn't in that workspace's rows. Because the lookup is workspace-scoped (`schema_id = $1 AND issue_id = $2`), an id belonging to a different workspace simply doesn't match and returns 404 rather than revealing that the id exists elsewhere. So a 403 means "wrong workspace credential" and a 404 means "right credential, id not here" — including the case where the id is real but lives in someone else's workspace. Super-admin credentials bypass both checks. @@ -82,7 +75,7 @@ $ curl -H "authorization: Basic " \ That returns `{"variations": [...], "variationsTruncated": false}` — every shape that event was seen in over the last 24 hours, each flagged with whether it is one of the shapes causing the issue. From there you can: - Narrow the response to one source and one app version, or switch it to CSV — see [listing event variations](#listing-event-variations). -- Read the issue's own counts broken down per app version with [the single-issue endpoint](#retrieving-a-single-issue), if you hold a Firebase ID token. +- Read the issue's own counts broken down per app version with [the single-issue endpoint](#retrieving-a-single-issue). Now that you have a working call, the sections below cover what the response does *not* tell you. @@ -143,10 +136,6 @@ Returns one issue with its counts **broken down per app version**, over a window `:issueId` is the issue's own id, the value returned as `issueId` in the response below. - -**Firebase ID token only.** Service account Basic and Avo OAuth JWT are both rejected with 401 on this endpoint. See [Authentication](#authentication) for what a service-account integration can call instead. - - ### Query parameters | Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | @@ -240,7 +229,8 @@ Read `regression` together with `issueStatus.status`: the Avo web app only surfa | Code | Body | Condition | | --- | --- | --- | | `200` | The issue object | At least one row matched. | -| `401` | `{"error": "Unauthorized"}` | Missing, invalid, or non-Firebase `Authorization` header — **or** an authenticated caller who is not a member of `:workspaceId`. | +| `401` | See [authentication error bodies](#authentication-error-bodies) | Missing or invalid credential. | +| `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | | `404` | `{"error": "Issue Not found"}` | Zero rows for this workspace and id. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | | `500` | `{"error": "Internal Server Error"}` | Database error. | @@ -251,7 +241,7 @@ There is no 400 on this endpoint. #### Request ```sh -$ curl -H "authorization: Bearer " \ +$ curl -H "authorization: Basic " \ -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/v3/2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26?time=7d" ``` @@ -315,8 +305,6 @@ A **variation** is one observed shape of an event: a distinct combination of pro That is what makes it the debugging endpoint. Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. -Accepts service account Basic, Avo OAuth JWT, or a Firebase ID token — so this is the endpoint a service-account integration can build on. - `:issueId` here is the same kind of id the single-issue endpoint takes. ### Query parameters From a67bfe182ed2b6b5f990a598f07b548514eb236a Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 10:24:58 +0000 Subject: [PATCH 05/11] Simplify the variations truncation guidance Drops the "never count rows" warning about undecodable rows making a truncated page look complete. The flag is now described plainly where the cap is explained: variationsTruncated: true means the response reached the 400-row cap, and sourceId / appVersion are how you narrow the query to get back under it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/inspector-issues.mdx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 4becbfd68..633ed3794 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -81,14 +81,14 @@ Now that you have a working call, the sections below cover what the response doe ## Before you integrate -Six behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Four of them cut across both endpoints and are covered here: +Five behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Four of them cut across both endpoints and are covered here: 1. [The time windows are fixed, and the freshest hour is missing](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing). 2. [`eventCount` is not "events affected by this issue"](#eventcount-is-not-events-affected-by-this-issue). 3. [`issueId` is a snapshot handle, not a durable key](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity). 4. [No response tells you which event variant was matched](#no-variant-attribution). -Two more are specific to the variations endpoint and are covered in its own section: [`variationsTruncated` is the only reliable completeness signal](#read-variationstruncated-never-count-rows), and [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. +One more is specific to the variations endpoint and is covered in its own section: [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. ### The time windows are fixed, and the freshest hour is missing @@ -327,11 +327,13 @@ That is what makes it the debugging endpoint. Put the causing shape next to the The query is capped at 400 rows, and both `app_version` and `source_id` are grouping keys — so one logical event shape yields **one row per app version per source**. An event with modest shape diversity across several versions and sources reaches the cap on cardinality alone. -Ordering and the limit are applied in the database, before anything you could filter client-side, so the shape you care about may already have been cut from the response. Filtering after the fact does not recover it. Passing `?sourceId=` and `?appVersion=` — taking the `sourceId` from the issue itself — is the sanctioned way to stay under the cap. +**`variationsTruncated: true` means you reached that cap** and the response is a partial view of the shapes for this event. + +Ordering and the limit are applied in the database, before anything you could filter client-side, so the shape you care about may already have been cut from the response. Filtering after the fact does not recover it. Narrow the query itself instead: pass `?sourceId=` — taking the `sourceId` from the issue — and `?appVersion=` to scope the response to the source and release you care about, and read the flag again to confirm you are now under the cap. ### Response -The envelope is `{"variations": [...], "variationsTruncated": bool}`. Each row has exactly these 13 fields: +The envelope is `{"variations": [...], "variationsTruncated": bool}`, where `variationsTruncated` is `true` if the response hit the [400-row cap](#staying-under-the-400-row-cap). Each row has exactly these 13 fields: | Field | Type | Notes | | --- | --- | --- | @@ -349,13 +351,6 @@ The envelope is `{"variations": [...], "variationsTruncated": bool}`. Each row h | `propertyNameSignature` | string[] | Observed property names, sorted by name. | | `propertyTypeSignature` | string[] | Observed property types. **Strictly parallel to `propertyNameSignature`** — same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. Both are built by mapping one list of (name, type) pairs sorted by name, and an event whose types cannot be fully parsed is dropped rather than emitted with a short array. | -#### Read `variationsTruncated`, never count rows - - -**Never compare `variations.length` to 400.**
-`variationsTruncated` is computed from the **raw** row count, but rows that fail to decode are dropped from the array you receive. So a truncated page can arrive with **399 rows and look complete**. The flag is the only reliable completeness signal. -
- #### Property names are raw observed names From 4519b96f414f3b99f8bb171789843d78fb956f1b Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 11:12:10 +0000 Subject: [PATCH 06/11] Rewrite the Inspector issues reference for a public audience Addresses review feedback that the page read like internal engineering notes rather than public API documentation. Documents service account authentication only, matching the other public API pages. Because a service account cannot hit the workspace-membership case, the 403 responses and the Bearer-specific error body go with it, and workspace scoping compresses to a single sentence. Cuts internal detail throughout: query filters, workspace ACLs, continuous aggregates and refresh policies, id hash formulas, storage mechanics, and decode behavior. Where one of those explained a real rule, the rule stays in plain terms without the machinery. Also per review: drops the /v3/ path callout rather than explaining the version-segment difference, answers where an issueId comes from in the opening paragraph, generalizes the variations use case, and states that no rate limit is enforced today but one may be introduced. Prose is down about 28%. All parameters, response fields, status codes, the CSV format and both examples are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- .../reference/public-api/inspector-issues.mdx | 190 ++++++------------ 1 file changed, 64 insertions(+), 126 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 633ed3794..26a485478 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -4,64 +4,35 @@ import { Callout } from 'nextra/components'; _Read Inspector issues and observed event shapes over HTTP_ -Two GET endpoints expose Inspector data outside the Avo web app: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId` you already hold, and both share a base URL, an authentication model and a workspace-scoping model. +Two GET endpoints expose Inspector data over HTTP: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId`, which you'll find in the Avo web app URL when you open an issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. -This page is written for someone wiring these endpoints into a script, a CI check, or an agent tool. The response body is your only view of the data, so every field, fallback and silent behavior is spelled out below. - -Base URL for both: `https://api.avo.app` +The base URL for the Avo public API is `https://api.avo.app`. ## Endpoints | Method and path | Returns | Reach for it when | | --- | --- | --- | -| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** — a bare object | You need per-app-version counts for one issue, or a window other than 24 hours. | -| `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You need the payloads themselves — which property names and types were actually sent — so you can diff the shape causing the issue against the healthy one. | +| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** | You need per-app-version counts for one issue, or a window other than 24 hours. | +| `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You want to see exactly what the event looked like when it triggered the issue. | `:workspaceId` is the ID of your workspace. You'll find it in the URL of your Avo tab after `/schemas/`. It is also returned as `schemaId` on every response object. -Both endpoints take an `issueId`. You'll find an issue's id in the Avo web app URL when you open that issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. - - -**Use the `/v3/` path exactly as written.** The single-issue endpoint is `/inspector/issues/v3/:issueId`. Dropping the `/v3/` segment does not reach this endpoint. - - ## Authentication -Both endpoints accept the same three credentials: - -- **Service account Basic** — `Authorization: Basic base64(name:secret)` -- **Avo OAuth JWT** — `Authorization: Bearer ...` -- **Firebase ID token** — `Authorization: Bearer ...` - -Neither endpoint requires an OAuth scope, and neither requires a particular workspace role — any workspace member passes. - -See [authorization header](/public-api/authentication#authenticating-with-avo-api) for how to build the Basic credential from a service account name and secret. The `Basic ` scheme is matched **case-sensitively**, so a lowercase `basic ` is not recognized as a service-account credential — it is treated as a malformed Bearer token and rejected with the message below. - -### Authentication error bodies - -Both endpoints run on the shared Avo API authenticator, which returns these four bodies. All of them use a `message` key, unlike the `error` key the endpoints themselves use for 400/404/500. +Both endpoints require an [authorization header](/public-api/authentication#authenticating-with-avo-api) containing a Base64 encoded service account name and secret. | Code | Body | Condition | | --- | --- | --- | | `401` | `{"message": "Authorization header missing"}` | No `Authorization` header at all. | -| `401` | `{"message": "Authorization header missing or invalid"}` | Unrecognized scheme, empty Bearer token, or any Bearer verification failure — an expired, revoked or wrong-project Firebase token and an invalid Avo OAuth JWT are indistinguishable here. | -| `401` | `{"message": "Invalid authorization"}` | Any Basic failure: bad secret, unknown service account, or a service account not registered in this workspace. | -| `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | - -A service account is never checked against the workspace ACL — its only workspace binding is the account record living under that workspace — so a service account can never produce the 403. - -### Workspace scoping +| `401` | `{"message": "Invalid authorization"}` | A bad secret, an unknown service account, or a service account that is not registered in this workspace. | -Every query filters on `schema_id`, so a credential can only ever see its own workspace's rows. That produces two different failures that are easy to confuse: +Authentication errors use a `message` key, unlike the `error` key the endpoints themselves use for 400, 404 and 500. -- **403 `{"message": "Access denied to workspace"}`** — the Bearer credential is valid, but its user is not in the ACL for `:workspaceId`. An unknown `:workspaceId` returns the same 403, because there is no ACL document to match against. A Basic credential whose service account is not registered in that workspace returns **401 `{"message": "Invalid authorization"}`** instead. -- **404** — the credential is valid *and* scoped to the right workspace, but the requested id isn't in that workspace's rows. Because the lookup is workspace-scoped (`schema_id = $1 AND issue_id = $2`), an id belonging to a different workspace simply doesn't match and returns 404 rather than revealing that the id exists elsewhere. - -So a 403 means "wrong workspace credential" and a 404 means "right credential, id not here" — including the case where the id is real but lives in someone else's workspace. Super-admin credentials bypass both checks. +Every lookup is scoped to your workspace, so an `issueId` that does not belong to it returns 404 — the same response as an id that does not exist at all. ### Rate limits -There is no rate limit on either of these endpoints. +No rate limit is currently enforced on these endpoints, but Avo may introduce one. Build retries with backoff into your integration and handle `429`. ## Your first call @@ -72,59 +43,40 @@ $ curl -H "authorization: Basic " \ -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations" ``` -That returns `{"variations": [...], "variationsTruncated": false}` — every shape that event was seen in over the last 24 hours, each flagged with whether it is one of the shapes causing the issue. From there you can: - -- Narrow the response to one source and one app version, or switch it to CSV — see [listing event variations](#listing-event-variations). -- Read the issue's own counts broken down per app version with [the single-issue endpoint](#retrieving-a-single-issue). - -Now that you have a working call, the sections below cover what the response does *not* tell you. +That returns `{"variations": [...], "variationsTruncated": false}` — every shape that event was seen in over the last 24 hours, each flagged with whether it is one of the shapes causing the issue. From there you can [narrow the response to one source and app version, or switch it to CSV](#listing-event-variations), or read [the issue's own counts broken down per app version](#retrieving-a-single-issue). ## Before you integrate -Five behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. Four of them cut across both endpoints and are covered here: +Four behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. A fifth applies to the variations endpoint only: [property names are the names the SDK sent](#property-names-are-the-names-the-sdk-sent). -1. [The time windows are fixed, and the freshest hour is missing](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing). -2. [`eventCount` is not "events affected by this issue"](#eventcount-is-not-events-affected-by-this-issue). -3. [`issueId` is a snapshot handle, not a durable key](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity). -4. [No response tells you which event variant was matched](#no-variant-attribution). +### Data freshness and time windows -One more is specific to the variations endpoint and is covered in its own section: [property names are the raw names the SDK sent](#property-names-are-raw-observed-names), not tracking-plan names. +The variations endpoint always looks back a fixed 24 hours — there is no parameter to widen or shift it. The single-issue endpoint is the one that takes a window, via its `time` parameter. -### The time windows are fixed, and the freshest hour is missing - -The variations endpoint looks back a fixed 24 hours. That window is a literal in the query, with no parameter to widen or shift it. The single-issue endpoint is the one that takes a window, via its `time` parameter. - - -**Expect roughly an hour of lag, and do not use these endpoints to verify a deploy you just shipped.**
-The counts are read from continuous aggregates refreshed on a ten-minute schedule with a one-hour end offset, which puts roughly an hour of lag on the freshest numbers. On the variations endpoint the aggregate is materialized-only, so **the most recent hour is not visible at all** — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) rather than these endpoints. + +**Do not use these endpoints to verify a deploy you just shipped.**
+Expect roughly an hour of lag on the counts, and on the variations endpoint the most recent hour is not visible at all — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) instead.
-Looking further back is not an option on the variations endpoint either: both the aggregate and the raw table drop data after 48 hours, so the 24-hour window is always fully covered and there is nothing older to read. - ### `eventCount` is not "events affected by this issue" - -**`eventCount` is the total volume of that event on that source in the window — every shape, healthy ones included.** `issueCount` is the per-issue figure: occurrences in the same window that actually violated.

-The number worth reporting is the ratio. `issueCount: 1428` against `eventCount: 96204` is a 1.5% violation rate on a high-volume event; reading `eventCount` as "affected events" overstates the blast radius by two orders of magnitude. -
+`eventCount` is the **total** volume of that event on that source in the window — every shape, healthy ones included. `issueCount` is the violating subset. + +The number worth reporting is the ratio between them. `issueCount: 1428` against `eventCount: 96204` is a 1.5% violation rate on a high-volume event; reading `eventCount` as "affected events" overstates the blast radius by two orders of magnitude. -### `issueId` is a snapshot handle; `sharedIssueId` is the identity +### `issueId` is a handle, `sharedIssueId` is the identity -`issueId` is `sha256(schemaId : sourceId : eventName : propertyName : issueType payload)` — the **full** encoded `issueType` payload is hashed. +`issueId` identifies one issue on one source, and it is not stable. It changes when the event or property behind the issue is edited in the tracking plan, and when a newly observed runtime type is added to an `InconsistentType` issue. When it changes, the old issue stops being updated with its original `firstSeen`, and a new issue starts with no history. -**`issueId` is not stable.** It changes when a tracking-plan edit moves a `propertyId`, `eventId` or `expectedPropertyType` inside the payload, and when a newly observed runtime type is appended to an `InconsistentType` issue's `propertyTypes`. Because `issue_id` is the primary key of the issues table, a changed hash creates a **new row**: the old issue is orphaned with its original `firstSeen`, and the new one starts fresh with no history. Treat `issueId` as a handle valid within one response or one session — safe to pass straight to `/variations`, not safe to persist as a long-lived key in your own database. +**Do not persist `issueId` as a long-lived key.** Treat it as a handle valid within one response or one session — safe to pass straight to `/variations`, not safe to store in your own database as durable identity. -`sharedIssueId` is `sha256(schemaId : eventName : propertyName : issueType)`, with `sourceId` omitted — that omission is what groups one logical problem across several sources. For `InconsistentType` the volatile `propertyTypes` array is deliberately excluded from the hash as well. - -That stability only goes so far, though. `sharedIssueId` is insulated from **newly observed types** and from **source**, but it is not immune to tracking-plan edits in general. For the five issue types other than `InconsistentType` it still hashes `propertyId` / `eventId` / `expectedPropertyType`, so a tracking-plan edit moves **both** ids. Only `InconsistentType` is fully insulated. +`sharedIssueId` is the stable identity. It leaves out the source, which is what groups one logical problem across several sources, and for `InconsistentType` it is unaffected by newly observed types. For the other issue types it still changes when the event or property behind the issue is edited in the tracking plan. -### No variant attribution +### Variant attribution is not available - -**Nothing in any response — JSON or CSV — tells you which event variant Inspector matched against.** There is no variant field in any of these payloads, no variant column in the underlying tables, and variant is not an input to either id hash. The tracking-plan model reaches the matcher already flattened, so variant identity is erased before validation and never reaches the issue row. It cannot be recovered from the response or from the id. If your tracking plan leans on [variants](/data-design/avo-tracking-plan/event-variants), expect to reconcile variant identity yourself. - +Nothing in either response — JSON or CSV — tells you which event variant Inspector matched against, and it cannot be recovered from the id. If your tracking plan leans on [variants](/data-design/avo-tracking-plan/event-variants), expect to reconcile variant identity yourself. ## Retrieving a single issue @@ -132,9 +84,7 @@ That stability only goes so far, though. `sharedIssueId` is insulated from **new GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId ``` -Returns one issue with its counts **broken down per app version**, over a window you choose. This is the endpoint to reach for when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. - -`:issueId` is the issue's own id, the value returned as `issueId` in the response below. +Returns one issue with its counts **broken down per app version**, over a window you choose. Reach for it when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. ### Query parameters @@ -142,16 +92,16 @@ Returns one issue with its counts **broken down per app version**, over a window | --- | --- | --- | --- | --- | --- | | `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | -`time` also selects the underlying rollup: `24h` reads the eight-hour aggregates, anything else reads the daily aggregate tables. The value is regex-sanitized before use. There is no `format`, no filtering and no pagination on this endpoint. +There is no `format`, no filtering and no pagination on this endpoint. ### Response -A **bare object**, not wrapped in an envelope, and **not** gzipped. +A single object, not wrapped in an envelope. | Field | Type | Notes | | --- | --- | --- | -| `issueId` | string, never null | sha256 hex. See [snapshot handle](#issueid-is-a-snapshot-handle-sharedissueid-is-the-identity) above. | -| `sharedIssueId` | string, never null | sha256 hex. Stable identity across sources. | +| `issueId` | string, never null | See [`issueId` is a handle](#issueid-is-a-handle-sharedissueid-is-the-identity) above. | +| `sharedIssueId` | string, never null | Stable identity across sources. | | `schemaId` | string | Your workspace ID. | | `sourceId` | string | A single source — an issue row is per-source. | | `eventName` | string | The event name as observed. | @@ -159,8 +109,8 @@ A **bare object**, not wrapped in an envelope, and **not** gzipped. | `issueType` | object | Tagged union, see below. | | `oldestAppVersion` | string | | | `newestAppVersion` | string | | -| `firstSeen` | string (ISO 8601) | Earliest first-seen for this issue row. | -| `lastSeen` | string (ISO 8601) | Max last-seen across versions, falling back to the last-seen day. | +| `firstSeen` | string (ISO 8601) | Earliest first-seen for this issue. | +| `lastSeen` | string (ISO 8601) | Max last-seen across versions. | | `issueCount` | number | Occurrences that **violated**, summed across versions. | | `eventCount` | number | **Total** occurrences of that event on that source, all shapes including healthy ones, summed across versions. | | `appVersions` | object | A **dictionary keyed by version string, not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | @@ -168,8 +118,6 @@ A **bare object**, not wrapped in an envelope, and **not** gzipped. | `regression` | boolean | Always present. `true` when this issue had been marked **Resolved** and was then observed again — see below. | | `branchIds` | string[] | Always present; `[]` when the issue is not linked to any branch. | -Top-level `issueCount` and `eventCount` are the sums across versions; top-level `lastSeen` is the max across versions, falling back to the last-seen day. - #### `issueType` A tagged union: `type` plus a payload key. The concepts behind each type are documented in [issue types in Inspector](/inspector/issue-types-in-inspector). @@ -183,7 +131,7 @@ A tagged union: `type` plus a payload key. The concepts behind each type are doc { "type": "InconsistentType", "inconsistentType": { "propertyName": "...", "propertyTypes": ["string", "int"] } } ``` -**Casing inconsistency to code around:** every payload key is camelCase *except* `PropertyTypeInconsistentWithTrackingPlan`, whose payload key repeats the PascalCase type name. `eventId` inside that payload is nullable; the other payloads' ids are not. +Every payload key is camelCase except `PropertyTypeInconsistentWithTrackingPlan`, whose payload key repeats the PascalCase type name. `eventId` inside that payload is nullable; the other payloads' ids are not. #### `issueStatus.status` @@ -207,7 +155,7 @@ An issue that has never had a status set reads as `Unresolved`. See [issue statu #### `regression` -`regression` is set to `true` when an issue a user had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: +`regression` is `true` when an issue someone had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: | `validateIn` | Regresses when the newly observed variation is | | --- | --- | @@ -229,10 +177,9 @@ Read `regression` together with `issueStatus.status`: the Avo web app only surfa | Code | Body | Condition | | --- | --- | --- | | `200` | The issue object | At least one row matched. | -| `401` | See [authentication error bodies](#authentication-error-bodies) | Missing or invalid credential. | -| `403` | `{"message": "Access denied to workspace"}` | A verified Bearer identity that is not a member of `:workspaceId`. | -| `404` | `{"error": "Issue Not found"}` | Zero rows for this workspace and id. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | -| `500` | `{"error": "Internal Server Error"}` | Database error. | +| `401` | See [authentication](#authentication) | Missing or invalid credential. | +| `404` | `{"error": "Issue Not found"}` | No issue with this id in your workspace. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | +| `500` | `{"error": "Internal Server Error"}` | Server error. | There is no 400 on this endpoint. @@ -301,23 +248,19 @@ $ curl -H "authorization: Basic " \ GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations ``` -A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. The single-issue endpoint tells you *that* an event is wrong and how often; this endpoint tells you *how* it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. - -That is what makes it the debugging endpoint. Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. +A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. The single-issue endpoint tells you _that_ an event is wrong and how often; this endpoint tells you _how_ it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. -`:issueId` here is the same kind of id the single-issue endpoint takes. +Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. ### Query parameters | Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | | --- | --- | --- | --- | --- | --- | | `format` | string | Optional | `json` | `csv`, case-insensitive | Anything else — including `""` and `xml` — returns JSON. Never errors. | -| `sourceId` | string | Optional | No source filter | **One** exact `source_id` | Blank or whitespace means no filter. | -| `appVersion` | string | Optional | No version filter | **One** exact `app_version` | Blank or whitespace means no filter. | +| `sourceId` | string | Optional | No source filter | **One** exact source ID | Blank or whitespace means no filter. | +| `appVersion` | string | Optional | No version filter | **One** exact app version | Blank or whitespace means no filter. | - -**`sourceId` and `appVersion` take single values only.** The filters are strict equality, so `?sourceId=a,b` matches the literal string `"a,b"` and returns nothing. Repeating a parameter — `?sourceId=a&sourceId=b` — arrives as an array, is parsed as absent, and the filter is **silently ignored** with no error. A non-string route parameter (for example a duplicated `:issueId`) returns `400 {"error": "Invalid request"}` *before* authentication runs. - +`sourceId` and `appVersion` take single values only. The filters are exact matches, so `?sourceId=a,b` matches the literal string `"a,b"` and returns nothing, and repeating a parameter — `?sourceId=a&sourceId=b` — is silently ignored with no error. **The issue's own source is not applied as a filter.** Without `?sourceId=`, you get variations of that **event name across every source in the workspace**, not just the source the issue was reported on. If you want the issue's own source, pass its `sourceId` explicitly. @@ -325,11 +268,9 @@ That is what makes it the debugging endpoint. Put the causing shape next to the #### Staying under the 400-row cap -The query is capped at 400 rows, and both `app_version` and `source_id` are grouping keys — so one logical event shape yields **one row per app version per source**. An event with modest shape diversity across several versions and sources reaches the cap on cardinality alone. - -**`variationsTruncated: true` means you reached that cap** and the response is a partial view of the shapes for this event. +The response is capped at 400 rows, and one logical event shape yields **one row per app version per source**. An event with modest shape diversity across several versions and sources reaches the cap on cardinality alone. -Ordering and the limit are applied in the database, before anything you could filter client-side, so the shape you care about may already have been cut from the response. Filtering after the fact does not recover it. Narrow the query itself instead: pass `?sourceId=` — taking the `sourceId` from the issue — and `?appVersion=` to scope the response to the source and release you care about, and read the flag again to confirm you are now under the cap. +**`variationsTruncated: true` means you reached that cap** and the response is a partial view of the shapes for this event. The cap is applied before you can filter client-side, so the shape you care about may already have been cut. Narrow the query itself instead: pass `?sourceId=` — taking the `sourceId` from the issue — and `?appVersion=` to scope the response to the source and release you care about, then read the flag again to confirm you are now under the cap. ### Response @@ -337,40 +278,40 @@ The envelope is `{"variations": [...], "variationsTruncated": bool}`, where `var | Field | Type | Notes | | --- | --- | --- | -| `eventVariationKey` | string | Identifies this shape. sha256 hex of `schemaId + sourceId + eventName + appVersion + propertyNameSignature + propertyTypeSignature`, so it changes whenever any of those change. | +| `eventVariationKey` | string | Identifies this shape on this source and app version. | | `causingIssue` | boolean | Whether this shape is one of the shapes causing the issue you asked about. | -| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally** — the pipeline sums `count / samplingRate` and rounds, so on a sampled source this is an extrapolated estimate. Treat it as an estimate when comparing against counts from your own systems. | +| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally** — on a sampled source this is an extrapolated estimate, so treat it as one when comparing against counts from your own systems. | | `eventName` | string | The event name as observed. | | `sourceId` | string | The Avo Source ID. | | `schemaId` | string | Your workspace ID. | -| `appVersion` | string \| null | Nullable in the encoder, but always populated on this endpoint — a row with no app version fails to decode and is dropped. | -| `minCreatedAt` | string \| null | ISO 8601. Invalid or infinite timestamps emit `null` rather than throwing. | -| `maxCreatedAt` | string \| null | ISO 8601, same guard. | -| `eventKey` | string \| null | **Not a tracking-plan ID.** sha256 hex of `schemaId + sourceId + eventName`, computed from the *observed* event name. All variations of one observed name on one source share it. Nullable in the encoder, always populated here. | -| `sourceKey` | string \| null | **Not the same value as `sourceId`.** The composite `schemaId + "-" + sourceId`. Use `sourceId` for anything that has to match an Avo Source. Nullable in the encoder, always populated here. | +| `appVersion` | string \| null | Always populated on this endpoint. | +| `minCreatedAt` | string \| null | ISO 8601. `null` when the timestamp is unavailable. | +| `maxCreatedAt` | string \| null | ISO 8601, same fallback. | +| `eventKey` | string \| null | Internal grouping value with no integration use. | +| `sourceKey` | string \| null | Internal grouping value with no integration use — it is **not** the same value as `sourceId`. Use `sourceId` for anything that has to match an Avo Source. | | `propertyNameSignature` | string[] | Observed property names, sorted by name. | -| `propertyTypeSignature` | string[] | Observed property types. **Strictly parallel to `propertyNameSignature`** — same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. Both are built by mapping one list of (name, type) pairs sorted by name, and an event whose types cannot be fully parsed is dropped rather than emitted with a short array. | +| `propertyTypeSignature` | string[] | Observed property types. **Strictly parallel to `propertyNameSignature`** — same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. | -#### Property names are raw observed names +#### Property names are the names the SDK sent -**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** These come straight from the event payload; nothing in that path consults the [Tracking Plan](/data-design/start-data-design). The only mutation is privacy redaction of values shaped like data in a name position, which surfaces as the literals ``, `` and ``. If you diff these against tracking-plan property names, reconcile naming conventions first or you will report false discrepancies.

-Redaction can also map two distinct names onto the **same** placeholder, so `propertyNameSignature` is not guaranteed to be free of duplicates. In the CSV those duplicates collapse into a single column and the last type wins. +**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** If you diff these against tracking-plan property names, reconcile naming conventions first or you will report false discrepancies.

+Names that look like data are redacted for privacy and surface as the literals ``, `` and ``. Redaction can map two distinct names onto the same placeholder, so `propertyNameSignature` is not guaranteed to be free of duplicates. In the CSV those duplicates collapse into a single column and the last type wins. -The window here is a fixed 24 hours, and the most recent hour is not visible at all — see [the time windows are fixed](#the-time-windows-are-fixed-and-the-freshest-hour-is-missing) above. +The window here is a fixed 24 hours, and the most recent hour is not visible — see [data freshness and time windows](#data-freshness-and-time-windows) above. ### CSV output `?format=csv` returns the same rows shaped for diffing: causing shapes in one section, healthy shapes in another, with one column per property name so the two halves line up column for column. Reach for it when you want to eyeball a shape difference or hand the result to a spreadsheet rather than parse it. -The response is `text/csv; charset=utf-8`, lines joined with `\n`, **no trailing newline** and no BOM. The structure is fixed: +The response is `text/csv; charset=utf-8`, lines joined with `\n` and **no trailing newline**. The structure is fixed: 1. **Line 0 is always the truncation marker**, emitted for both verdicts: `# variationsTruncated: true` or `# variationsTruncated: false`. 2. `# Variations causing the issue`, then a header line, then the causing rows. 3. `# Variations not causing the issue`, then the **same** header line again, then the remaining rows. -Both section headers are emitted even when a section is empty, and both sections repeat an identical header line so the two halves diff column for column. Causing rows come first. +Both section headers are emitted even when a section is empty. Causing rows come first. Columns, in order: @@ -381,7 +322,7 @@ min_created_at, max_created_at ...followed by **one column per property name**: the union of `propertyNameSignature` across all rows, deduped in first-appearance order, with the causing rows scanned first. `causing_issue` is an explicit column rendered `true` / `false`. -Each property cell holds the **type** of that property in that row, and an empty cell when the row does not carry the property. A cell can also hold the literal `unknown`, which means the row supplied the name but no type at that position — a defensive fallback that the current ingestion path should never produce, but worth handling if you parse strictly. Date cells fall back to an empty cell rather than throwing on an invalid timestamp. +Each property cell holds the **type** of that property in that row, and an empty cell when the row does not carry the property. A cell can also hold the literal `unknown`, meaning the row supplied the name but no type at that position — worth handling if you parse strictly. Date cells fall back to an empty cell rather than failing on an invalid timestamp. Quoting: every cell — **including the header line** — is wrapped in double quotes, except an empty string, which stays bare. Internal `"` is doubled. A cell starting with `=`, `+`, `-`, `@`, tab, CR or LF is prefixed with `'` as a CSV injection guard. @@ -403,13 +344,10 @@ In that example the second causing row has no `payment_method` property, so its | Code | Body | Condition | | --- | --- | --- | | `200` | JSON or CSV | Success. | -| `400` | `{"error": "Invalid request"}` | A non-string route parameter — for example a duplicated `:issueId`. Checked **before** authentication. | -| `401` | `{"message": "Authorization header missing"}`, `{"message": "Authorization header missing or invalid"}` or `{"message": "Invalid authorization"}` | Missing or invalid credential — see [authentication error bodies](#authentication-error-bodies) for which is which. | -| `403` | `{"message": "Access denied to workspace"}` | Valid Bearer credential, not a member of `:workspaceId`. | -| `404` | `{"error": "Issue not found"}` | The id is not in this workspace's rows. Note the lowercase `not found`, unlike the single-issue endpoint's `Issue Not found`. | -| `500` | `{"error": "Internal Server Error"}` | Identity error, row-fetch error, connection-pool failure, or an uncaught throw. | - -This endpoint fails closed on the causing-key lookup: if that lookup errors it returns 500 rather than a 200 with every row marked non-causing. +| `400` | `{"error": "Invalid request"}` | A malformed route parameter — for example a duplicated `:issueId`. Checked **before** authentication. | +| `401` | See [authentication](#authentication) | Missing or invalid credential. | +| `404` | `{"error": "Issue not found"}` | No issue with this id in your workspace. Note the lowercase `not found`, unlike the single-issue endpoint's `Issue Not found`. | +| `500` | `{"error": "Internal Server Error"}` | Server error. | ### Example @@ -460,7 +398,7 @@ $ curl -H "authorization: Basic " \ } ``` -The two shapes carry the same property names and differ only in the type of `revenue` — `string` on the shape causing the issue, `float` on the healthy one. That diff, plus the `count` ratio, is what these endpoints are for. Note that `eventKey` and `sourceKey` are identical on both rows: they identify the observed event name and the source, not the shape. +The two shapes carry the same property names and differ only in the type of `revenue` — `string` on the shape causing the issue, `float` on the healthy one. That diff, plus the `count` ratio, is what these endpoints are for. ## What's next? From dcd2c0cf12f809bd28fb4c275ccb6c2724a82a1f Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 11:58:34 +0000 Subject: [PATCH 07/11] Set the rate limit section up for future enforcement The previous wording led with "no rate limit is currently enforced", which invites integrations designed around unlimited throughput that break the day a limit is introduced. Now leads with the expectation instead: treat the endpoints as rate limited, design against the 1 request per second per service account that the rest of the Avo public API soft-enforces, retry on 429 with exponential backoff and Retry-After, and reach out for higher sustained volume. Does not claim a limit is enforced on these endpoints today. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/inspector-issues.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 26a485478..92e671af6 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -32,7 +32,11 @@ Every lookup is scoped to your workspace, so an `issueId` that does not belong t ### Rate limits -No rate limit is currently enforced on these endpoints, but Avo may introduce one. Build retries with backoff into your integration and handle `429`. +Treat these endpoints as rate limited. They are not throttled today, but the rest of the Avo public API soft-enforces **1 request per second per service account**, and that is the figure to design against — an integration built to that budget will not break if enforcement is introduced here. + +Retry on `429 Too Many Requests` rather than failing: back off exponentially instead of retrying immediately, and respect `Retry-After` if it is present. Poll on a schedule rather than in a tight loop. + +If your use case needs a higher sustained request rate, [reach out](/help/troubleshooting). ## Your first call From 1ddc320c2d7d30600cb7738ccf588f2a077dceab Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 12:34:37 +0000 Subject: [PATCH 08/11] Rewrite the prose in a plainer voice The page read as over-polished: em dashes used for dramatic asides, balanced "X is not Y, Z is the W" constructions, and sentences that editorialized about the significance of the fact just stated. Rewrites the prose in shorter, flatter sentences that say the thing and stop. Em dashes drop from 20 to 7, mostly in table cells. Section headings lose the antithesis phrasing. No facts, field names, parameters, status codes or examples change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- .../reference/public-api/inspector-issues.mdx | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 92e671af6..aa4a01a99 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -4,13 +4,13 @@ import { Callout } from 'nextra/components'; _Read Inspector issues and observed event shapes over HTTP_ -Two GET endpoints expose Inspector data over HTTP: a single issue, and the observed event shapes ("variations") behind an issue. Both are addressed by an `issueId`, which you'll find in the Avo web app URL when you open an issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. +Two GET endpoints let you read Inspector data over HTTP: one returns a single issue, the other returns the event shapes ("variations") behind it. Both take an `issueId`. You'll find one in the Avo web app URL when you open an issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. The base URL for the Avo public API is `https://api.avo.app`. ## Endpoints -| Method and path | Returns | Reach for it when | +| Method and path | Returns | Use it when | | --- | --- | --- | | `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** | You need per-app-version counts for one issue, or a window other than 24 hours. | | `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You want to see exactly what the event looked like when it triggered the issue. | @@ -26,61 +26,61 @@ Both endpoints require an [authorization header](/public-api/authentication#auth | `401` | `{"message": "Authorization header missing"}` | No `Authorization` header at all. | | `401` | `{"message": "Invalid authorization"}` | A bad secret, an unknown service account, or a service account that is not registered in this workspace. | -Authentication errors use a `message` key, unlike the `error` key the endpoints themselves use for 400, 404 and 500. +Authentication errors use a `message` key. The endpoints themselves use `error` for 400, 404 and 500. -Every lookup is scoped to your workspace, so an `issueId` that does not belong to it returns 404 — the same response as an id that does not exist at all. +Lookups are scoped to your workspace. An `issueId` from another workspace returns 404, the same as an id that doesn't exist. ### Rate limits -Treat these endpoints as rate limited. They are not throttled today, but the rest of the Avo public API soft-enforces **1 request per second per service account**, and that is the figure to design against — an integration built to that budget will not break if enforcement is introduced here. +Treat these endpoints as rate limited. We don't throttle them today, but the rest of the Avo public API soft-enforces **1 request per second per service account**. Design for that, and your integration won't break if we start enforcing a limit here too. -Retry on `429 Too Many Requests` rather than failing: back off exponentially instead of retrying immediately, and respect `Retry-After` if it is present. Poll on a schedule rather than in a tight loop. +Retry on `429 Too Many Requests` instead of failing. Back off exponentially, and respect `Retry-After` if it's there. Poll on a schedule, not in a tight loop. -If your use case needs a higher sustained request rate, [reach out](/help/troubleshooting). +If you need a higher sustained request rate, [reach out](/help/troubleshooting). ## Your first call -Once you have a credential and an `issueId`, the event shapes behind that issue are a single request: +With a credential and an `issueId`, one request gets you the event shapes behind that issue: ```sh $ curl -H "authorization: Basic " \ -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations" ``` -That returns `{"variations": [...], "variationsTruncated": false}` — every shape that event was seen in over the last 24 hours, each flagged with whether it is one of the shapes causing the issue. From there you can [narrow the response to one source and app version, or switch it to CSV](#listing-event-variations), or read [the issue's own counts broken down per app version](#retrieving-a-single-issue). +You get back `{"variations": [...], "variationsTruncated": false}`: every shape that event was seen in over the last 24 hours, each flagged with whether it's causing the issue. From there you can [narrow the response to one source and app version, or switch it to CSV](#listing-event-variations), or read [the issue's own counts broken down per app version](#retrieving-a-single-issue). ## Before you integrate -Four behaviors are not visible anywhere in the response body, and each one produces a plausible-looking but wrong integration when it is assumed away. A fifth applies to the variations endpoint only: [property names are the names the SDK sent](#property-names-are-the-names-the-sdk-sent). +Four things about this data aren't visible in the response body. Get any of them wrong and your integration will look correct while reporting the wrong numbers. A fifth applies only to the variations endpoint: [property names are the names the SDK sent](#property-names-are-the-names-the-sdk-sent). ### Data freshness and time windows -The variations endpoint always looks back a fixed 24 hours — there is no parameter to widen or shift it. The single-issue endpoint is the one that takes a window, via its `time` parameter. +The variations endpoint always looks back 24 hours. There's no parameter to widen or shift that window. Only the single-issue endpoint lets you pick one, with `time`. -**Do not use these endpoints to verify a deploy you just shipped.**
-Expect roughly an hour of lag on the counts, and on the variations endpoint the most recent hour is not visible at all — a deploy 20 minutes old shows nothing there. If you are validating an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger) instead. +**Don't use these endpoints to check a deploy you just shipped.**
+Counts lag by about an hour, and the variations endpoint can't see the most recent hour at all, so a deploy that went out 20 minutes ago shows nothing. To validate an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger).
### `eventCount` is not "events affected by this issue" -`eventCount` is the **total** volume of that event on that source in the window — every shape, healthy ones included. `issueCount` is the violating subset. +`eventCount` is the **total** volume of that event on that source in the window, counting every shape including the healthy ones. `issueCount` is just the part that violated. -The number worth reporting is the ratio between them. `issueCount: 1428` against `eventCount: 96204` is a 1.5% violation rate on a high-volume event; reading `eventCount` as "affected events" overstates the blast radius by two orders of magnitude. +What you usually want is the ratio. `issueCount: 1428` out of `eventCount: 96204` is a 1.5% violation rate. Read `eventCount` as "events affected" and you'll report a problem about 70 times bigger than it is. -### `issueId` is a handle, `sharedIssueId` is the identity +### Which id to store -`issueId` identifies one issue on one source, and it is not stable. It changes when the event or property behind the issue is edited in the tracking plan, and when a newly observed runtime type is added to an `InconsistentType` issue. When it changes, the old issue stops being updated with its original `firstSeen`, and a new issue starts with no history. +`issueId` identifies one issue on one source, but it isn't stable. Editing the event or property behind the issue in your tracking plan changes it, and so does a new runtime type showing up on an `InconsistentType` issue. When it changes you get a new issue with no history, and the old one stops updating. -**Do not persist `issueId` as a long-lived key.** Treat it as a handle valid within one response or one session — safe to pass straight to `/variations`, not safe to store in your own database as durable identity. +**Don't store `issueId` as a long-lived key.** Holding it for a response or a session is fine, and passing it straight to `/variations` is fine. Keying your own database on it isn't. -`sharedIssueId` is the stable identity. It leaves out the source, which is what groups one logical problem across several sources, and for `InconsistentType` it is unaffected by newly observed types. For the other issue types it still changes when the event or property behind the issue is edited in the tracking plan. +`sharedIssueId` is the stable one. It ignores the source, so the same problem on three sources shares a single `sharedIssueId`, and new runtime types don't change it on `InconsistentType` issues. It does still change on other issue types if you edit the event or property in your tracking plan. ### Variant attribution is not available -Nothing in either response — JSON or CSV — tells you which event variant Inspector matched against, and it cannot be recovered from the id. If your tracking plan leans on [variants](/data-design/avo-tracking-plan/event-variants), expect to reconcile variant identity yourself. +Neither response tells you which event variant Inspector matched against, in JSON or in CSV, and you can't work it out from the id. If you use [variants](/data-design/avo-tracking-plan/event-variants), you'll have to match them up yourself. ## Retrieving a single issue @@ -88,7 +88,7 @@ Nothing in either response — JSON or CSV — tells you which event variant Ins GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId ``` -Returns one issue with its counts **broken down per app version**, over a window you choose. Reach for it when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. +Returns one issue with its counts **broken down per app version**, over a window you choose. Use it when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. ### Query parameters @@ -96,18 +96,18 @@ Returns one issue with its counts **broken down per app version**, over a window | --- | --- | --- | --- | --- | --- | | `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | -There is no `format`, no filtering and no pagination on this endpoint. +This endpoint has no `format`, filtering or pagination. ### Response -A single object, not wrapped in an envelope. +A single object, with no envelope around it. | Field | Type | Notes | | --- | --- | --- | -| `issueId` | string, never null | See [`issueId` is a handle](#issueid-is-a-handle-sharedissueid-is-the-identity) above. | +| `issueId` | string, never null | See [which id to store](#which-id-to-store) above. | | `sharedIssueId` | string, never null | Stable identity across sources. | | `schemaId` | string | Your workspace ID. | -| `sourceId` | string | A single source — an issue row is per-source. | +| `sourceId` | string | A single source. Each issue row covers one source. | | `eventName` | string | The event name as observed. | | `propertyName` | string \| null | `null` for event-level issue types. | | `issueType` | object | Tagged union, see below. | @@ -124,7 +124,7 @@ A single object, not wrapped in an envelope. #### `issueType` -A tagged union: `type` plus a payload key. The concepts behind each type are documented in [issue types in Inspector](/inspector/issue-types-in-inspector). +A tagged union: a `type` plus a payload key. For what each type means, see [issue types in Inspector](/inspector/issue-types-in-inspector). ```json { "type": "EventNotInTrackingPlan" } @@ -135,7 +135,7 @@ A tagged union: `type` plus a payload key. The concepts behind each type are doc { "type": "InconsistentType", "inconsistentType": { "propertyName": "...", "propertyTypes": ["string", "int"] } } ``` -Every payload key is camelCase except `PropertyTypeInconsistentWithTrackingPlan`, whose payload key repeats the PascalCase type name. `eventId` inside that payload is nullable; the other payloads' ids are not. +Payload keys are camelCase, except on `PropertyTypeInconsistentWithTrackingPlan`, where the key repeats the PascalCase type name. Its `eventId` can be null; the ids in the other payloads can't. #### `issueStatus.status` @@ -147,7 +147,7 @@ Every payload key is camelCase except `PropertyTypeInconsistentWithTrackingPlan` `validateIn` is one of `{"type":"CurrentAppVersion","appVersion":string}`, `{"type":"NextAppVersion","appVersion":string}`, `{"type":"CustomAppVersion","appVersion":string}`, `{"type":"Date","date":ISO 8601}` or `{"type":"Never"}`. -Note the naming shift between the label you set in the Avo web app and the value you read back: +The label you set in the Avo web app doesn't always match the value you read back: | Avo web app label | `issueStatus.status.type` | | --- | --- | @@ -159,7 +159,7 @@ An issue that has never had a status set reads as `Unresolved`. See [issue statu #### `regression` -`regression` is `true` when an issue someone had marked **Resolved** is observed again past the point at which it was supposed to be fixed. Inspector then moves the issue back to `Unresolved` and flags it. "Past the point it was supposed to be fixed" is exactly the `validateIn` recorded on the resolution: +`regression` is `true` when an issue someone marked **Resolved** shows up again after the point it was meant to be fixed. Inspector moves it back to `Unresolved` and sets the flag. What counts as "after the point it was meant to be fixed" comes from the `validateIn` recorded when it was resolved: | `validateIn` | Regresses when the newly observed variation is | | --- | --- | @@ -169,12 +169,12 @@ An issue that has never had a status set reads as `Unresolved`. See [issue statu | `Date(t)` | seen **after** `t` | | `Never` | never — the issue is not reopened and never flagged | -Two things to code around: +Two things to watch for: -- **`Ignored` does not produce a regression.** An ignored issue that resurfaces is also moved back to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. -- **The flag is cleared the moment anyone sets the status manually again**, to any value. A newly created issue is never a regression. +- **`Ignored` never produces a regression.** An ignored issue that comes back also moves to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. +- **Setting the status manually clears the flag**, whatever you set it to. A new issue is never a regression. -Read `regression` together with `issueStatus.status`: the Avo web app only surfaces it while the status is `Unresolved`, which is the only state it is meaningful in. +Read `regression` alongside `issueStatus.status`. The Avo web app only shows it while the status is `Unresolved`, which is the only state where it means anything. ### Status codes @@ -182,7 +182,7 @@ Read `regression` together with `issueStatus.status`: the Avo web app only surfa | --- | --- | --- | | `200` | The issue object | At least one row matched. | | `401` | See [authentication](#authentication) | Missing or invalid credential. | -| `404` | `{"error": "Issue Not found"}` | No issue with this id in your workspace. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the exact casing. | +| `404` | `{"error": "Issue Not found"}` | No issue with this id in your workspace. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the capital `N` in `Not`. | | `500` | `{"error": "Internal Server Error"}` | Server error. | There is no 400 on this endpoint. @@ -252,9 +252,9 @@ $ curl -H "authorization: Basic " \ GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations ``` -A **variation** is one observed shape of an event: a distinct combination of property names and property types, per app version, per source. The single-issue endpoint tells you _that_ an event is wrong and how often; this endpoint tells you _how_ it is wrong, by returning every shape that event was seen in alongside a `causingIssue` flag and an occurrence count. +A **variation** is one observed shape of an event: a particular combination of property names and types, per app version, per source. The single-issue endpoint tells you an event is wrong and how often. This one shows you what was actually sent, returning every shape the event was seen in with a `causingIssue` flag and a count. -Put the causing shape next to the healthy one and the diff — a property missing here, a type differing there, and the volume split between them — is usually the whole story. Available as JSON or, with `?format=csv`, as a two-section CSV built for exactly that diff. +Comparing a causing shape against a healthy one usually shows you the problem: a missing property, a type that changed, and how the volume splits between them. Use `?format=csv` for a two-section CSV laid out for that comparison. ### Query parameters @@ -264,27 +264,27 @@ Put the causing shape next to the healthy one and the diff — a property missin | `sourceId` | string | Optional | No source filter | **One** exact source ID | Blank or whitespace means no filter. | | `appVersion` | string | Optional | No version filter | **One** exact app version | Blank or whitespace means no filter. | -`sourceId` and `appVersion` take single values only. The filters are exact matches, so `?sourceId=a,b` matches the literal string `"a,b"` and returns nothing, and repeating a parameter — `?sourceId=a&sourceId=b` — is silently ignored with no error. +`sourceId` and `appVersion` take one value each and match exactly. `?sourceId=a,b` looks for a source literally named `a,b` and finds nothing. Repeating a parameter, as in `?sourceId=a&sourceId=b`, is ignored without an error. -**The issue's own source is not applied as a filter.** Without `?sourceId=`, you get variations of that **event name across every source in the workspace**, not just the source the issue was reported on. If you want the issue's own source, pass its `sourceId` explicitly. +**The issue's own source isn't applied as a filter.** Without `?sourceId=`, you get that **event name across every source in your workspace**, not just the source the issue was reported on. Pass the issue's `sourceId` if that's what you want. #### Staying under the 400-row cap -The response is capped at 400 rows, and one logical event shape yields **one row per app version per source**. An event with modest shape diversity across several versions and sources reaches the cap on cardinality alone. +The response is capped at 400 rows, and each event shape produces **one row per app version per source**. An event with only a handful of shapes can still hit the cap if it's live across a lot of versions and sources. -**`variationsTruncated: true` means you reached that cap** and the response is a partial view of the shapes for this event. The cap is applied before you can filter client-side, so the shape you care about may already have been cut. Narrow the query itself instead: pass `?sourceId=` — taking the `sourceId` from the issue — and `?appVersion=` to scope the response to the source and release you care about, then read the flag again to confirm you are now under the cap. +**`variationsTruncated: true` means you hit that cap** and you're only seeing part of the picture. The cap applies before anything reaches you, so filtering the response afterwards won't bring back a shape that was cut. Narrow the query instead: pass `?sourceId=` (use the issue's own source) and `?appVersion=`, then check the flag again. ### Response -The envelope is `{"variations": [...], "variationsTruncated": bool}`, where `variationsTruncated` is `true` if the response hit the [400-row cap](#staying-under-the-400-row-cap). Each row has exactly these 13 fields: +The envelope is `{"variations": [...], "variationsTruncated": bool}`. `variationsTruncated` is `true` if the response hit the [400-row cap](#staying-under-the-400-row-cap). Each row has these 13 fields: | Field | Type | Notes | | --- | --- | --- | | `eventVariationKey` | string | Identifies this shape on this source and app version. | | `causingIssue` | boolean | Whether this shape is one of the shapes causing the issue you asked about. | -| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally** — on a sampled source this is an extrapolated estimate, so treat it as one when comparing against counts from your own systems. | +| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally.** On a sampled source it's an estimate, so expect it to differ from counts in your own systems. | | `eventName` | string | The event name as observed. | | `sourceId` | string | The Avo Source ID. | | `schemaId` | string | Your workspace ID. | @@ -294,28 +294,28 @@ The envelope is `{"variations": [...], "variationsTruncated": bool}`, where `var | `eventKey` | string \| null | Internal grouping value with no integration use. | | `sourceKey` | string \| null | Internal grouping value with no integration use — it is **not** the same value as `sourceId`. Use `sourceId` for anything that has to match an Avo Source. | | `propertyNameSignature` | string[] | Observed property names, sorted by name. | -| `propertyTypeSignature` | string[] | Observed property types. **Strictly parallel to `propertyNameSignature`** — same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. | +| `propertyTypeSignature` | string[] | Observed property types. **Parallel to `propertyNameSignature`**: same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. | #### Property names are the names the SDK sent -**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** If you diff these against tracking-plan property names, reconcile naming conventions first or you will report false discrepancies.

-Names that look like data are redacted for privacy and surface as the literals ``, `` and ``. Redaction can map two distinct names onto the same placeholder, so `propertyNameSignature` is not guaranteed to be free of duplicates. In the CSV those duplicates collapse into a single column and the last type wins. +**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** Line these up against your tracking-plan property names before comparing them, or you'll report differences that aren't real.

+Names that look like data are redacted for privacy and come back as ``, `` or ``. Two different names can redact to the same placeholder, so `propertyNameSignature` can contain duplicates. In the CSV they collapse into one column and the last type wins. -The window here is a fixed 24 hours, and the most recent hour is not visible — see [data freshness and time windows](#data-freshness-and-time-windows) above. +The window here is a fixed 24 hours, and the most recent hour isn't visible. See [data freshness and time windows](#data-freshness-and-time-windows) above. ### CSV output -`?format=csv` returns the same rows shaped for diffing: causing shapes in one section, healthy shapes in another, with one column per property name so the two halves line up column for column. Reach for it when you want to eyeball a shape difference or hand the result to a spreadsheet rather than parse it. +`?format=csv` returns the same rows arranged for comparison: causing shapes in one section, healthy shapes in another, with one column per property name so the two halves line up. Use it when you want to eyeball a difference or open the result in a spreadsheet rather than parse it. The response is `text/csv; charset=utf-8`, lines joined with `\n` and **no trailing newline**. The structure is fixed: -1. **Line 0 is always the truncation marker**, emitted for both verdicts: `# variationsTruncated: true` or `# variationsTruncated: false`. +1. **Line 0 is always the truncation marker**, present either way: `# variationsTruncated: true` or `# variationsTruncated: false`. 2. `# Variations causing the issue`, then a header line, then the causing rows. 3. `# Variations not causing the issue`, then the **same** header line again, then the remaining rows. -Both section headers are emitted even when a section is empty. Causing rows come first. +Causing rows come first, and both section headers appear even when a section is empty. Columns, in order: @@ -324,11 +324,11 @@ event_variation_key, causing_issue, count, event_name, source_id, app_version, min_created_at, max_created_at ``` -...followed by **one column per property name**: the union of `propertyNameSignature` across all rows, deduped in first-appearance order, with the causing rows scanned first. `causing_issue` is an explicit column rendered `true` / `false`. +...followed by **one column per property name**: every name in `propertyNameSignature` across all rows, in the order they first appear, with the causing rows scanned first. `causing_issue` is its own column, rendered `true` / `false`. -Each property cell holds the **type** of that property in that row, and an empty cell when the row does not carry the property. A cell can also hold the literal `unknown`, meaning the row supplied the name but no type at that position — worth handling if you parse strictly. Date cells fall back to an empty cell rather than failing on an invalid timestamp. +Each property cell holds the **type** of that property in that row, or is empty if the row doesn't carry the property. A cell can also read `unknown`, meaning the row gave a name but no type at that position, so handle it if you parse strictly. Date cells come back empty rather than failing on an invalid timestamp. -Quoting: every cell — **including the header line** — is wrapped in double quotes, except an empty string, which stays bare. Internal `"` is doubled. A cell starting with `=`, `+`, `-`, `@`, tab, CR or LF is prefixed with `'` as a CSV injection guard. +Every cell is wrapped in double quotes, **including the header line**. Empty cells stay bare, and an internal `"` is doubled. A cell starting with `=`, `+`, `-`, `@`, tab, CR or LF gets a `'` in front of it to guard against CSV injection. ```csv # variationsTruncated: false @@ -341,7 +341,7 @@ Quoting: every cell — **including the header line** — is wrapped in double q "e93c4a70b1d582f6047ae3c9128d5b0f76a2e841c30f9b57d6812ac4053e7fb9","false","94776","Checkout Completed","9Zq7YAo0R","8.14.2","2026-08-23T07:00:00.000Z","2026-08-24T06:00:00.000Z","string","string","float" ``` -In that example the second causing row has no `payment_method` property, so its cell is bare. +In the example above, the second causing row has no `payment_method` property, so that cell is empty. ### Status codes @@ -402,13 +402,13 @@ $ curl -H "authorization: Basic " \ } ``` -The two shapes carry the same property names and differ only in the type of `revenue` — `string` on the shape causing the issue, `float` on the healthy one. That diff, plus the `count` ratio, is what these endpoints are for. +Both shapes carry the same property names. The only difference is the type of `revenue`: `string` on the shape causing the issue, `float` on the healthy one. That, together with the `count` on each row, is usually enough to find the bug. ## What's next? -Now that you can read issues over HTTP, the conceptual docs explain what you are looking at and what to do about it: +For what the data means and what to do about it: -- [Issue types in Inspector](/inspector/issue-types-in-inspector) — what each `issueType` detects, in the same language the Avo web app uses. -- [Inspector issues view](/inspector/inspector-issues-view) — the same issues in the Avo web app, including issue statuses and regressions. -- [Fixing issues found in Inspector](/inspector/inspector-fix-issues) — turning a variation diff into a tracking plan or implementation change. -- [Authentication](/public-api/authentication#authenticating-with-avo-api) — creating a service account and building the `Authorization` header. +- [Issue types in Inspector](/inspector/issue-types-in-inspector): what each `issueType` detects, in the same language the Avo web app uses. +- [Inspector issues view](/inspector/inspector-issues-view): the same issues in the Avo web app, including statuses and regressions. +- [Fixing issues found in Inspector](/inspector/inspector-fix-issues): turning a shape difference into a tracking plan or implementation change. +- [Authentication](/public-api/authentication#authenticating-with-avo-api): creating a service account and building the `Authorization` header. From 25eda7dff3041080285ed84396e4349c5be8602d Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Tue, 25 Aug 2026 15:13:28 +0000 Subject: [PATCH 09/11] Tighten the Inspector issues reference Second pass on the review note that this is a public doc and should carry only what someone needs to call these two endpoints. Structural: drops the "Your first call" section, which duplicated the per-endpoint examples, and the "Before you integrate" preamble that sat between the reader and the reference. Its four warnings now appear as short callouts in the sections they apply to, so the endpoint you came for is the first thing after authentication. Trimmed: the regression validateIn table down to two sentences, the CSV section by about half, the status code tables, and the field table notes. Removed the parameter regex, the error-string casing asides, the request ordering note on the 400, and the internal derivation of eventKey and sourceKey, which are now one line saying they can be ignored. 415 lines to 319, with no em dashes left. All parameters, response fields, status codes and both examples are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- .../reference/public-api/inspector-issues.mdx | 263 ++++++------------ 1 file changed, 84 insertions(+), 179 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index aa4a01a99..4da57a445 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -2,9 +2,9 @@ import { Callout } from 'nextra/components'; # Inspector Issues -_Read Inspector issues and observed event shapes over HTTP_ +_Read Inspector issues and the event shapes behind them over HTTP_ -Two GET endpoints let you read Inspector data over HTTP: one returns a single issue, the other returns the event shapes ("variations") behind it. Both take an `issueId`. You'll find one in the Avo web app URL when you open an issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. +Two GET endpoints: one returns a single Inspector issue, the other returns the event shapes ("variations") behind it. Both take an `issueId`, which you'll find in the Avo web app URL when you open an issue: `https://www.avo.app/schemas/{workspaceId}/inspector/issues/ii/{issueId}`. The base URL for the Avo public API is `https://api.avo.app`. @@ -12,10 +12,14 @@ The base URL for the Avo public API is `https://api.avo.app`. | Method and path | Returns | Use it when | | --- | --- | --- | -| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A **single issue** | You need per-app-version counts for one issue, or a window other than 24 hours. | -| `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The **event shapes** behind an issue, as JSON or CSV | You want to see exactly what the event looked like when it triggered the issue. | +| `GET /workspaces/:workspaceId/inspector/issues/v3/:issueId` | A single issue | You need counts broken down per app version, or a window other than 24 hours. | +| `GET /workspaces/:workspaceId/inspector/issues/:issueId/variations` | The event shapes behind an issue, as JSON or CSV | You want to see exactly what the event looked like when it triggered the issue. | -`:workspaceId` is the ID of your workspace. You'll find it in the URL of your Avo tab after `/schemas/`. It is also returned as `schemaId` on every response object. +`:workspaceId` is the ID of your workspace, from the URL of your Avo tab after `/schemas/`. + + +**Counts lag by about an hour**, and variations don't include the most recent hour at all. A deploy that went out 20 minutes ago shows nothing. To check an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger). + ## Authentication @@ -23,64 +27,16 @@ Both endpoints require an [authorization header](/public-api/authentication#auth | Code | Body | Condition | | --- | --- | --- | -| `401` | `{"message": "Authorization header missing"}` | No `Authorization` header at all. | -| `401` | `{"message": "Invalid authorization"}` | A bad secret, an unknown service account, or a service account that is not registered in this workspace. | - -Authentication errors use a `message` key. The endpoints themselves use `error` for 400, 404 and 500. +| `401` | `{"message": "Authorization header missing"}` | No `Authorization` header. | +| `401` | `{"message": "Invalid authorization"}` | Bad secret, unknown service account, or a service account not registered in this workspace. | -Lookups are scoped to your workspace. An `issueId` from another workspace returns 404, the same as an id that doesn't exist. +An `issueId` from another workspace returns 404, the same as an id that doesn't exist. ### Rate limits -Treat these endpoints as rate limited. We don't throttle them today, but the rest of the Avo public API soft-enforces **1 request per second per service account**. Design for that, and your integration won't break if we start enforcing a limit here too. - -Retry on `429 Too Many Requests` instead of failing. Back off exponentially, and respect `Retry-After` if it's there. Poll on a schedule, not in a tight loop. - -If you need a higher sustained request rate, [reach out](/help/troubleshooting). - -## Your first call - -With a credential and an `issueId`, one request gets you the event shapes behind that issue: - -```sh -$ curl -H "authorization: Basic " \ - -X GET "https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations" -``` - -You get back `{"variations": [...], "variationsTruncated": false}`: every shape that event was seen in over the last 24 hours, each flagged with whether it's causing the issue. From there you can [narrow the response to one source and app version, or switch it to CSV](#listing-event-variations), or read [the issue's own counts broken down per app version](#retrieving-a-single-issue). - -## Before you integrate - -Four things about this data aren't visible in the response body. Get any of them wrong and your integration will look correct while reporting the wrong numbers. A fifth applies only to the variations endpoint: [property names are the names the SDK sent](#property-names-are-the-names-the-sdk-sent). - -### Data freshness and time windows - -The variations endpoint always looks back 24 hours. There's no parameter to widen or shift that window. Only the single-issue endpoint lets you pick one, with `time`. - - -**Don't use these endpoints to check a deploy you just shipped.**
-Counts lag by about an hour, and the variations endpoint can't see the most recent hour at all, so a deploy that went out 20 minutes ago shows nothing. To validate an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger). -
- -### `eventCount` is not "events affected by this issue" - -`eventCount` is the **total** volume of that event on that source in the window, counting every shape including the healthy ones. `issueCount` is just the part that violated. - -What you usually want is the ratio. `issueCount: 1428` out of `eventCount: 96204` is a 1.5% violation rate. Read `eventCount` as "events affected" and you'll report a problem about 70 times bigger than it is. - -### Which id to store - -`issueId` identifies one issue on one source, but it isn't stable. Editing the event or property behind the issue in your tracking plan changes it, and so does a new runtime type showing up on an `InconsistentType` issue. When it changes you get a new issue with no history, and the old one stops updating. - - -**Don't store `issueId` as a long-lived key.** Holding it for a response or a session is fine, and passing it straight to `/variations` is fine. Keying your own database on it isn't. - - -`sharedIssueId` is the stable one. It ignores the source, so the same problem on three sources shares a single `sharedIssueId`, and new runtime types don't change it on `InconsistentType` issues. It does still change on other issue types if you edit the event or property in your tracking plan. - -### Variant attribution is not available +Treat these endpoints as rate limited. Design for **1 request per second per service account**, the limit soft-enforced across the rest of the Avo public API. Retry on `429 Too Many Requests` with exponential backoff, and respect `Retry-After` if it's there. -Neither response tells you which event variant Inspector matched against, in JSON or in CSV, and you can't work it out from the id. If you use [variants](/data-design/avo-tracking-plan/event-variants), you'll have to match them up yourself. +If you need a higher sustained rate, [reach out](/help/troubleshooting). ## Retrieving a single issue @@ -88,43 +44,47 @@ Neither response tells you which event variant Inspector matched against, in JSO GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/v3/:issueId ``` -Returns one issue with its counts **broken down per app version**, over a window you choose. Use it when you need to know which release a problem is concentrated in, or when 24 hours is the wrong window. +Returns one issue, with its counts broken down per app version. ### Query parameters -| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | -| --- | --- | --- | --- | --- | --- | -| `time` | string | Optional | `24h` | Matches `^(\d+)([hd])$`, case-insensitive — for example `12h`, `7d`, `30D` | **Silently coerced to 24 hours.** No 400. | - -This endpoint has no `format`, filtering or pagination. +| Parameter | Type | Default | Accepted values | +| --- | --- | --- | --- | +| `time` | string | `24h` | A number followed by `h` or `d`, such as `12h` or `7d`. Anything else falls back to `24h`. | ### Response -A single object, with no envelope around it. - | Field | Type | Notes | | --- | --- | --- | -| `issueId` | string, never null | See [which id to store](#which-id-to-store) above. | -| `sharedIssueId` | string, never null | Stable identity across sources. | +| `issueId` | string | Identifies this issue on this source. Not stable, see below. | +| `sharedIssueId` | string | Stable identity for the same problem across sources. | | `schemaId` | string | Your workspace ID. | -| `sourceId` | string | A single source. Each issue row covers one source. | -| `eventName` | string | The event name as observed. | +| `sourceId` | string | The source this issue was found on. | +| `eventName` | string | The event name as sent. | | `propertyName` | string \| null | `null` for event-level issue types. | -| `issueType` | object | Tagged union, see below. | +| `issueType` | object | What kind of issue this is. See below. | | `oldestAppVersion` | string | | | `newestAppVersion` | string | | -| `firstSeen` | string (ISO 8601) | Earliest first-seen for this issue. | -| `lastSeen` | string (ISO 8601) | Max last-seen across versions. | -| `issueCount` | number | Occurrences that **violated**, summed across versions. | -| `eventCount` | number | **Total** occurrences of that event on that source, all shapes including healthy ones, summed across versions. | -| `appVersions` | object | A **dictionary keyed by version string, not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | -| `issueStatus` | object | `{status, updatedAt: string \| null, updatedBy: string \| null}` | -| `regression` | boolean | Always present. `true` when this issue had been marked **Resolved** and was then observed again — see below. | -| `branchIds` | string[] | Always present; `[]` when the issue is not linked to any branch. | +| `firstSeen` | string (ISO 8601) | | +| `lastSeen` | string (ISO 8601) | | +| `issueCount` | number | Occurrences that violated, summed across versions. | +| `eventCount` | number | **Total** occurrences of that event on that source, including the ones that were fine. | +| `appVersions` | object | Keyed by version string, **not an array**. Each value is `{"appVersion": string, "issueCount": number, "eventCount": number, "lastSeen": string \| null}`. | +| `issueStatus` | object | `{status, updatedAt: string \| null, updatedBy: string \| null}`. See below. | +| `regression` | boolean | See below. | +| `branchIds` | string[] | `[]` when the issue isn't linked to a branch. | + + +**`eventCount` is not the number of events affected by the issue.** It is the total volume of that event on that source, healthy occurrences included. `issueCount` is the part that violated. Report the ratio between them: `issueCount: 1428` out of `eventCount: 96204` is a 1.5% violation rate, not 96,204 broken events. + + + +**Don't store `issueId` as a long-lived key.** It changes when you edit the event or property behind the issue in your tracking plan, and when a new type shows up on an `InconsistentType` issue. When it changes you get a new issue with no history. Use `sharedIssueId` if you need a durable identity. + #### `issueType` -A tagged union: a `type` plus a payload key. For what each type means, see [issue types in Inspector](/inspector/issue-types-in-inspector). +A `type` plus a payload key. For what each type means, see [issue types in Inspector](/inspector/issue-types-in-inspector). ```json { "type": "EventNotInTrackingPlan" } @@ -135,7 +95,7 @@ A tagged union: a `type` plus a payload key. For what each type means, see [issu { "type": "InconsistentType", "inconsistentType": { "propertyName": "...", "propertyTypes": ["string", "int"] } } ``` -Payload keys are camelCase, except on `PropertyTypeInconsistentWithTrackingPlan`, where the key repeats the PascalCase type name. Its `eventId` can be null; the ids in the other payloads can't. +Payload keys are camelCase, except on `PropertyTypeInconsistentWithTrackingPlan`, where the key repeats the PascalCase type name. #### `issueStatus.status` @@ -147,57 +107,30 @@ Payload keys are camelCase, except on `PropertyTypeInconsistentWithTrackingPlan` `validateIn` is one of `{"type":"CurrentAppVersion","appVersion":string}`, `{"type":"NextAppVersion","appVersion":string}`, `{"type":"CustomAppVersion","appVersion":string}`, `{"type":"Date","date":ISO 8601}` or `{"type":"Never"}`. -The label you set in the Avo web app doesn't always match the value you read back: - -| Avo web app label | `issueStatus.status.type` | -| --- | --- | -| Unresolved | `Unresolved` | -| **Ignore** | `Ignored` | -| Resolved | `Resolved` | - -An issue that has never had a status set reads as `Unresolved`. See [issue status](/inspector/inspector-issues-view#issue-status) for what each one means. +The status labelled **Ignore** in the Avo web app reads back as `Ignored`. An issue that never had a status set reads as `Unresolved`. See [issue status](/inspector/inspector-issues-view#issue-status) for what each one means. #### `regression` -`regression` is `true` when an issue someone marked **Resolved** shows up again after the point it was meant to be fixed. Inspector moves it back to `Unresolved` and sets the flag. What counts as "after the point it was meant to be fixed" comes from the `validateIn` recorded when it was resolved: - -| `validateIn` | Regresses when the newly observed variation is | -| --- | --- | -| `CurrentAppVersion(v)` | on app version **≥ v** | -| `CustomAppVersion(v)` | on app version **≥ v** | -| `NextAppVersion(v)` | on app version **strictly > v** | -| `Date(t)` | seen **after** `t` | -| `Never` | never — the issue is not reopened and never flagged | - -Two things to watch for: +`true` when an issue that was marked **Resolved** came back after the point it was meant to be fixed, which is the `validateIn` recorded when it was resolved. Inspector moves the issue back to `Unresolved` and sets the flag. -- **`Ignored` never produces a regression.** An ignored issue that comes back also moves to `Unresolved`, but `regression` stays `false`. Only `Resolved` sets it. -- **Setting the status manually clears the flag**, whatever you set it to. A new issue is never a regression. - -Read `regression` alongside `issueStatus.status`. The Avo web app only shows it while the status is `Unresolved`, which is the only state where it means anything. +Setting the status again clears it, and an `Ignored` issue that comes back never sets it. It only means anything while the status is `Unresolved`. ### Status codes | Code | Body | Condition | | --- | --- | --- | -| `200` | The issue object | At least one row matched. | +| `200` | The issue object | | | `401` | See [authentication](#authentication) | Missing or invalid credential. | -| `404` | `{"error": "Issue Not found"}` | No issue with this id in your workspace. Covers an unknown id, a malformed id, and an id belonging to a **different** workspace. Note the capital `N` in `Not`. | -| `500` | `{"error": "Internal Server Error"}` | Server error. | - -There is no 400 on this endpoint. +| `404` | `{"error": "Issue Not found"}` | No issue with this id in your workspace. | +| `500` | `{"error": "Internal Server Error"}` | | ### Example -#### Request - ```sh $ curl -H "authorization: Basic " \ -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/v3/2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26?time=7d" ``` -#### Response - ```json { "issueId": "2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26", @@ -252,83 +185,63 @@ $ curl -H "authorization: Basic " \ GET https://api.avo.app/workspaces/:workspaceId/inspector/issues/:issueId/variations ``` -A **variation** is one observed shape of an event: a particular combination of property names and types, per app version, per source. The single-issue endpoint tells you an event is wrong and how often. This one shows you what was actually sent, returning every shape the event was seen in with a `causingIssue` flag and a count. +A **variation** is one observed shape of an event: a particular combination of property names and types, per app version, per source. This endpoint returns every shape the event was seen in over the last 24 hours, each with a `causingIssue` flag and a count, so you can compare a shape that triggered the issue against one that didn't. -Comparing a causing shape against a healthy one usually shows you the problem: a missing property, a type that changed, and how the volume splits between them. Use `?format=csv` for a two-section CSV laid out for that comparison. +The window is always 24 hours and can't be changed. ### Query parameters -| Parameter | Type | Required | Default when omitted | Accepted values | On invalid input | -| --- | --- | --- | --- | --- | --- | -| `format` | string | Optional | `json` | `csv`, case-insensitive | Anything else — including `""` and `xml` — returns JSON. Never errors. | -| `sourceId` | string | Optional | No source filter | **One** exact source ID | Blank or whitespace means no filter. | -| `appVersion` | string | Optional | No version filter | **One** exact app version | Blank or whitespace means no filter. | +| Parameter | Type | Default | Accepted values | +| --- | --- | --- | --- | +| `format` | string | `json` | `csv` returns CSV. Anything else returns JSON. | +| `sourceId` | string | No filter | One source ID. | +| `appVersion` | string | No filter | One app version. | -`sourceId` and `appVersion` take one value each and match exactly. `?sourceId=a,b` looks for a source literally named `a,b` and finds nothing. Repeating a parameter, as in `?sourceId=a&sourceId=b`, is ignored without an error. +`sourceId` and `appVersion` take a single value each and match exactly, so `?sourceId=a,b` finds nothing. Repeating a parameter is ignored. -**The issue's own source isn't applied as a filter.** Without `?sourceId=`, you get that **event name across every source in your workspace**, not just the source the issue was reported on. Pass the issue's `sourceId` if that's what you want. +**Without `?sourceId=`, you get the event across every source in your workspace**, not just the source the issue was reported on. Pass the issue's own `sourceId` if that's what you want. -#### Staying under the 400-row cap - -The response is capped at 400 rows, and each event shape produces **one row per app version per source**. An event with only a handful of shapes can still hit the cap if it's live across a lot of versions and sources. - -**`variationsTruncated: true` means you hit that cap** and you're only seeing part of the picture. The cap applies before anything reaches you, so filtering the response afterwards won't bring back a shape that was cut. Narrow the query instead: pass `?sourceId=` (use the issue's own source) and `?appVersion=`, then check the flag again. +The response is capped at 400 rows, and each shape produces one row per app version per source. `variationsTruncated: true` means you hit the cap and are only seeing part of the picture. Narrow the query with `sourceId` and `appVersion`, then check the flag again. ### Response -The envelope is `{"variations": [...], "variationsTruncated": bool}`. `variationsTruncated` is `true` if the response hit the [400-row cap](#staying-under-the-400-row-cap). Each row has these 13 fields: +`{"variations": [...], "variationsTruncated": bool}`, where each row has: | Field | Type | Notes | | --- | --- | --- | | `eventVariationKey` | string | Identifies this shape on this source and app version. | -| `causingIssue` | boolean | Whether this shape is one of the shapes causing the issue you asked about. | -| `count` | number | Occurrences of this shape in the window. **Sampling-adjusted, not a raw tally.** On a sampled source it's an estimate, so expect it to differ from counts in your own systems. | -| `eventName` | string | The event name as observed. | +| `causingIssue` | boolean | Whether this shape is one of the shapes causing the issue. | +| `count` | number | Occurrences in the window. An estimate on sampled sources. | +| `eventName` | string | The event name as sent. | | `sourceId` | string | The Avo Source ID. | | `schemaId` | string | Your workspace ID. | -| `appVersion` | string \| null | Always populated on this endpoint. | -| `minCreatedAt` | string \| null | ISO 8601. `null` when the timestamp is unavailable. | -| `maxCreatedAt` | string \| null | ISO 8601, same fallback. | -| `eventKey` | string \| null | Internal grouping value with no integration use. | -| `sourceKey` | string \| null | Internal grouping value with no integration use — it is **not** the same value as `sourceId`. Use `sourceId` for anything that has to match an Avo Source. | -| `propertyNameSignature` | string[] | Observed property names, sorted by name. | -| `propertyTypeSignature` | string[] | Observed property types. **Parallel to `propertyNameSignature`**: same length, same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. | +| `appVersion` | string \| null | | +| `minCreatedAt` | string \| null | ISO 8601. | +| `maxCreatedAt` | string \| null | ISO 8601. | +| `propertyNameSignature` | string[] | The property names that were sent, sorted by name. | +| `propertyTypeSignature` | string[] | Their types, in the same order, so `propertyTypeSignature[i]` is the type of `propertyNameSignature[i]`. | -#### Property names are the names the SDK sent +Rows also carry `eventKey` and `sourceKey`, which you can ignore. -**`propertyNameSignature` holds the names the SDK actually sent, not tracking-plan names.** Line these up against your tracking-plan property names before comparing them, or you'll report differences that aren't real.

-Names that look like data are redacted for privacy and come back as ``, `` or ``. Two different names can redact to the same placeholder, so `propertyNameSignature` can contain duplicates. In the CSV they collapse into one column and the last type wins. +**`propertyNameSignature` holds the names the SDK actually sent, not your tracking plan's names.** Line them up before comparing, or you'll report differences that aren't real. Names that look like data are redacted and come back as ``, `` or ``. -The window here is a fixed 24 hours, and the most recent hour isn't visible. See [data freshness and time windows](#data-freshness-and-time-windows) above. +Nothing in the response says which event [variant](/data-design/avo-tracking-plan/event-variants) Inspector matched against. ### CSV output -`?format=csv` returns the same rows arranged for comparison: causing shapes in one section, healthy shapes in another, with one column per property name so the two halves line up. Use it when you want to eyeball a difference or open the result in a spreadsheet rather than parse it. - -The response is `text/csv; charset=utf-8`, lines joined with `\n` and **no trailing newline**. The structure is fixed: - -1. **Line 0 is always the truncation marker**, present either way: `# variationsTruncated: true` or `# variationsTruncated: false`. -2. `# Variations causing the issue`, then a header line, then the causing rows. -3. `# Variations not causing the issue`, then the **same** header line again, then the remaining rows. - -Causing rows come first, and both section headers appear even when a section is empty. - -Columns, in order: +`?format=csv` returns the same rows arranged for comparison, as `text/csv; charset=utf-8`: -``` -event_variation_key, causing_issue, count, event_name, source_id, app_version, -min_created_at, max_created_at -``` +1. `# variationsTruncated: true` or `false`. +2. `# Variations causing the issue`, a header row, then the causing rows. +3. `# Variations not causing the issue`, the same header row, then the rest. -...followed by **one column per property name**: every name in `propertyNameSignature` across all rows, in the order they first appear, with the causing rows scanned first. `causing_issue` is its own column, rendered `true` / `false`. +Both sections appear even when empty. Columns are `event_variation_key`, `causing_issue`, `count`, `event_name`, `source_id`, `app_version`, `min_created_at`, `max_created_at`, then one column per property name, in the order the names first appear. -Each property cell holds the **type** of that property in that row, or is empty if the row doesn't carry the property. A cell can also read `unknown`, meaning the row gave a name but no type at that position, so handle it if you parse strictly. Date cells come back empty rather than failing on an invalid timestamp. - -Every cell is wrapped in double quotes, **including the header line**. Empty cells stay bare, and an internal `"` is doubled. A cell starting with `=`, `+`, `-`, `@`, tab, CR or LF gets a `'` in front of it to guard against CSV injection. +Each property cell holds that property's type in that row, or is empty if the row doesn't carry it. Every cell is quoted, including the header row; empty cells stay bare. ```csv # variationsTruncated: false @@ -341,29 +254,23 @@ Every cell is wrapped in double quotes, **including the header line**. Empty cel "e93c4a70b1d582f6047ae3c9128d5b0f76a2e841c30f9b57d6812ac4053e7fb9","false","94776","Checkout Completed","9Zq7YAo0R","8.14.2","2026-08-23T07:00:00.000Z","2026-08-24T06:00:00.000Z","string","string","float" ``` -In the example above, the second causing row has no `payment_method` property, so that cell is empty. - ### Status codes | Code | Body | Condition | | --- | --- | --- | -| `200` | JSON or CSV | Success. | -| `400` | `{"error": "Invalid request"}` | A malformed route parameter — for example a duplicated `:issueId`. Checked **before** authentication. | +| `200` | JSON or CSV | | +| `400` | `{"error": "Invalid request"}` | Malformed request parameters. | | `401` | See [authentication](#authentication) | Missing or invalid credential. | -| `404` | `{"error": "Issue not found"}` | No issue with this id in your workspace. Note the lowercase `not found`, unlike the single-issue endpoint's `Issue Not found`. | -| `500` | `{"error": "Internal Server Error"}` | Server error. | +| `404` | `{"error": "Issue not found"}` | No issue with this id in your workspace. | +| `500` | `{"error": "Internal Server Error"}` | | ### Example -#### Request - ```sh $ curl -H "authorization: Basic " \ -X GET "https://api.avo.app/workspaces/hAtPI0dEsq/inspector/issues/2f1c9b8e4d7a05c3e6b1a94f8d2c70b5e93a17d4c8f0b62a5d1e7c3948fb0a26/variations?sourceId=9Zq7YAo0R&appVersion=8.14.2" ``` -#### Response - ```json { "variations": [ @@ -402,13 +309,11 @@ $ curl -H "authorization: Basic " \ } ``` -Both shapes carry the same property names. The only difference is the type of `revenue`: `string` on the shape causing the issue, `float` on the healthy one. That, together with the `count` on each row, is usually enough to find the bug. +Both shapes carry the same property names. The only difference is the type of `revenue`: `string` on the shape causing the issue, `float` on the healthy one. ## What's next? -For what the data means and what to do about it: - -- [Issue types in Inspector](/inspector/issue-types-in-inspector): what each `issueType` detects, in the same language the Avo web app uses. -- [Inspector issues view](/inspector/inspector-issues-view): the same issues in the Avo web app, including statuses and regressions. -- [Fixing issues found in Inspector](/inspector/inspector-fix-issues): turning a shape difference into a tracking plan or implementation change. -- [Authentication](/public-api/authentication#authenticating-with-avo-api): creating a service account and building the `Authorization` header. +- [Issue types in Inspector](/inspector/issue-types-in-inspector): what each `issueType` detects. +- [Inspector issues view](/inspector/inspector-issues-view): the same issues in the Avo web app. +- [Fixing issues found in Inspector](/inspector/inspector-fix-issues): turning a shape difference into a fix. +- [Authentication](/public-api/authentication#authenticating-with-avo-api): creating a service account. From 92abb92c0b0f806cb8e283e4b038f0cbeeda5301 Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Wed, 26 Aug 2026 10:14:14 +0000 Subject: [PATCH 10/11] Correct the data freshness claim The page claimed counts lag by about an hour and that variations exclude the most recent hour entirely. That is wrong: the aggregates behind these endpoints are real-time, so recent data is included rather than hidden until it is precomputed. Replaces it with the actual constraint, which is ingestion latency of a few minutes, and keeps the pointer to the Inspector Debugger for verifying an implementation as you ship it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/inspector-issues.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 4da57a445..228c8dfc3 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -17,8 +17,8 @@ The base URL for the Avo public API is `https://api.avo.app`. `:workspaceId` is the ID of your workspace, from the URL of your Avo tab after `/schemas/`. - -**Counts lag by about an hour**, and variations don't include the most recent hour at all. A deploy that went out 20 minutes ago shows nothing. To check an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger). + +**Inspector data takes a few minutes to arrive**, so an event sent moments ago won't be in a response yet. To check an implementation as you ship it, use the [Inspector Debugger](/inspector/inspector-debugger). ## Authentication From 3720decbc26f8807b6a6dacdaa864f34b86c6198 Mon Sep 17 00:00:00 2001 From: bjornj12 Date: Wed, 26 Aug 2026 10:15:13 +0000 Subject: [PATCH 11/11] Remove the issueId stability warning The claim that issueId changes and shouldn't be stored was wrong. An issue keeps its issueId; a different problem produces a separate issue with its own id. Drops the callout and the "not stable" note in the field table, and describes sharedIssueId by what it does rather than by contrast. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JQppoqtYPWnv1c5m2XvPVa --- pages/reference/public-api/inspector-issues.mdx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pages/reference/public-api/inspector-issues.mdx b/pages/reference/public-api/inspector-issues.mdx index 228c8dfc3..db3df2c67 100644 --- a/pages/reference/public-api/inspector-issues.mdx +++ b/pages/reference/public-api/inspector-issues.mdx @@ -56,8 +56,8 @@ Returns one issue, with its counts broken down per app version. | Field | Type | Notes | | --- | --- | --- | -| `issueId` | string | Identifies this issue on this source. Not stable, see below. | -| `sharedIssueId` | string | Stable identity for the same problem across sources. | +| `issueId` | string | Identifies this issue on this source. | +| `sharedIssueId` | string | Groups the same problem across sources. | | `schemaId` | string | Your workspace ID. | | `sourceId` | string | The source this issue was found on. | | `eventName` | string | The event name as sent. | @@ -78,10 +78,6 @@ Returns one issue, with its counts broken down per app version. **`eventCount` is not the number of events affected by the issue.** It is the total volume of that event on that source, healthy occurrences included. `issueCount` is the part that violated. Report the ratio between them: `issueCount: 1428` out of `eventCount: 96204` is a 1.5% violation rate, not 96,204 broken events. - -**Don't store `issueId` as a long-lived key.** It changes when you edit the event or property behind the issue in your tracking plan, and when a new type shows up on an `InconsistentType` issue. When it changes you get a new issue with no history. Use `sharedIssueId` if you need a durable identity. - - #### `issueType` A `type` plus a payload key. For what each type means, see [issue types in Inspector](/inspector/issue-types-in-inspector).