Skip to main content

Traces — User Feedback API

All endpoints verified working via curl. Document updated only after curl confirmation.

User/human feedback attached to a trace, or to one span within it. Feedback is free-form: a caller supplies at least one of rating, label, comment — none are individually required. Auth: requireAnyAuth (session cookie or personal/team API key) for both posting and reading; any team role. Team-scoped throughout — a trace outside the caller's team behaves as if it doesn't exist (404), same as GET /api/v1/traces/:id.

spanId in every request/response is the caller-supplied OTel span reference (e.g. "s1", the same value used in POST /api/v1/traces and returned by GET /api/v1/traces/:id) — never the internal span UUID. Trace-level feedback (no span) has spanId: null.

Feedback can be edited after posting (PATCH /api/v1/traces/:id/feedback/:feedbackId), but only by the row's original author (createdBy) — see that section below. Deleting feedback is not supported. updatedAt equals createdAt until the row has been edited.


POST /api/v1/traces/:id/feedback

Attaches feedback to a trace. Body accepts rating (integer -1..5), label (string, <=200 chars), comment (string, <=5000 chars), spanId (OTel ref, optional — omit for whole-trace feedback), and source (one of user, developer, end_user, api; defaults to user). At least one of rating/label/comment is required. createdBy is the caller's user id; verified non-null here for a personal API key (per the controller, it is req.user?.id ?? null — a team-scoped key with no associated user would yield null, though that path was not separately exercised).

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rating": -1, "label": "wrong_answer", "comment": "The tool call missed relevant docs.", "source": "end_user"}'

Response (status 201):

{
"id": "d71ba68f-a7bf-424d-a424-a0ed80ec1592",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "end_user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:11.976Z",
"updatedAt": "2026-07-05T02:33:11.976Z"
}

Span-level feedback (spanId)

Supplying spanId attaches the feedback to one span instead of the whole trace. The value is the OTel ref from the original POST /api/v1/traces call (here "s1", the LLM span) — the response echoes that same ref back, not the span's internal UUID.

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rating": 5, "spanId": "s1"}'

Response (status 201):

{
"id": "bd505193-ea39-4e23-a9b5-c356d38b5064",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s1",
"rating": 5,
"label": null,
"comment": null,
"source": "user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.590Z",
"updatedAt": "2026-07-05T02:33:24.590Z"
}

A second span-level example — label only, no rating, source "developer", against span "s2" (a tool span, child of "s1"):

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "good_tool_use", "spanId": "s2", "source": "developer"}'

Response (status 201):

{
"id": "f848d26d-5f1f-4d18-8d47-2dc76675fb21",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s2",
"rating": null,
"label": "good_tool_use",
"comment": null,
"source": "developer",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.608Z",
"updatedAt": "2026-07-05T02:33:24.608Z"
}

PATCH /api/v1/traces/:id/feedback/:feedbackId

Edits an existing feedback row's rating/label/comment in place (author-only — only the user who originally posted the row, matched via createdBy, may edit it; rows posted via a team API key have createdBy: null and so cannot be edited by anyone). Fields omitted from the body keep their existing value; null explicitly clears a field. Deleting feedback is not supported.

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": 1}'

Response (status 201):

{
"id": "7133430d-9e2c-4bb3-90b9-f56e95bde619",
"traceId": "a856684b-79cf-4a83-ac8f-f50294ec07dc",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "user",
"createdBy": "e0016d77-79da-4094-8bc9-e5ba5ff84527",
"createdAt": "2026-07-05T18:14:13.237Z",
"updatedAt": "2026-07-05T18:14:13.237Z"
}

The author corrects the rating; label/comment are omitted, so they keep their existing values:

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": 1}'

Response (status 200) — rating flipped, label/comment unchanged, updatedAt advanced:

{
"id": "7133430d-9e2c-4bb3-90b9-f56e95bde619",
"traceId": "a856684b-79cf-4a83-ac8f-f50294ec07dc",
"spanId": null,
"rating": 1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "user",
"createdBy": "e0016d77-79da-4094-8bc9-e5ba5ff84527",
"createdAt": "2026-07-05T18:14:13.237Z",
"updatedAt": "2026-07-05T18:14:22.536Z"
}

Error responses

Clearing rating/label/comment down to nothing (status 400, same message as the create-side validation — the merged result, not just the patch body, is checked):

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": null, "label": null, "comment": null}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Provide at least one of rating, label, or comment." } }

A teammate who is not the row's author (status 403) — the row is left unchanged:

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" \
-d '{"rating": -1}'
{ "error": { "code": "FORBIDDEN", "message": "Only the original author can edit this feedback." } }

Unknown feedback id, or one belonging to another team's trace (status 404 — never distinguishes the two, same as the trace-lookup 404s above):

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/00000000-0000-0000-0000-000000000000 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "NOT_FOUND", "message": "Feedback not found." } }

No Authorization header (status 401):

curl -X PATCH $ACRUXCORE_BASE_URL/traces/a856684b-79cf-4a83-ac8f-f50294ec07dc/feedback/7133430d-9e2c-4bb3-90b9-f56e95bde619 \
-H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }

GET /api/v1/traces/:id/feedback

Lists all feedback on a trace (trace-level and span-level, mixed), newest first. Continuing the example above, after the three POSTs:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback"

Response (status 200):

{
"data": [
{
"id": "f848d26d-5f1f-4d18-8d47-2dc76675fb21",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s2",
"rating": null,
"label": "good_tool_use",
"comment": null,
"source": "developer",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.608Z",
"updatedAt": "2026-07-05T02:33:24.608Z"
},
{
"id": "bd505193-ea39-4e23-a9b5-c356d38b5064",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": "s1",
"rating": 5,
"label": null,
"comment": null,
"source": "user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:24.590Z",
"updatedAt": "2026-07-05T02:33:24.590Z"
},
{
"id": "d71ba68f-a7bf-424d-a424-a0ed80ec1592",
"traceId": "fbb5f16e-521b-4839-b9ab-78ace8c1f0a4",
"spanId": null,
"rating": -1,
"label": "wrong_answer",
"comment": "The tool call missed relevant docs.",
"source": "end_user",
"createdBy": "8e16d0ed-e41a-4006-b098-5e4aecd4998d",
"createdAt": "2026-07-05T02:33:11.976Z",
"updatedAt": "2026-07-05T02:33:11.976Z"
}
]
}

The same three feedback rows also appear, unchanged, in the additive feedback field of GET /api/v1/traces/:id (see docs/api/traces/traces.mdx) — verified byte-for-byte identical to the data array above.


GET /api/v1/traces/feedback/summary

Average rating + counts, grouped by model or prompt_version (query param group_by, default prompt_version). Optional from/to (ISO 8601) restrict the window; default is the last 30 days. downCount is the number of rows with rating < 0 (thumbs-down) in that bucket; avgRating is null for a bucket with no rated feedback.

Every bucket carries three identity fields:

  • key — the raw grouping value: a model name, or a prompt version UUID.
  • label — the readable name to display: the model name, or "<prompt name> v<version number>". Falls back to the raw id if the version row no longer exists.
  • promptId — the prompt owning that version, so a client can link the bucket to its prompt. Always null when grouping by model.

Buckets are ordered by label.

Attribution is per-TRACE, not per-span: every feedback row on a trace is counted against every distinct model/prompt_version used by that trace's llm spans, regardless of which span (or none) the feedback itself is attached to (see aggregate() in feedback.repository.ts).

The two calls below were run against a trace with one llm span (model gpt-4o-mini, stamped with version 1 of a prompt named support-reply) carrying two trace-level feedback rows, ratings -1 and 1. from narrows the window to just that run.

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback/summary?from=2026-09-06T11:57:29Z"

Response (status 200) — count 2; avgRating 0 (mean of -1 and 1); downCount 1:

{
"groupBy": "prompt_version",
"buckets": [
{
"key": "3219b466-b070-4a0d-a666-a38c372419b9",
"label": "support-reply v1",
"promptId": "2b4708b9-9845-4a73-9a04-edd13c8364c0",
"count": 2,
"avgRating": 0,
"downCount": 1
}
]
}

The same window grouped by model — label repeats the model name and promptId is null:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback/summary?group_by=model&from=2026-09-06T11:57:29Z"

Response (status 200):

{
"groupBy": "model",
"buckets": [
{
"key": "gpt-4o-mini",
"label": "gpt-4o-mini",
"promptId": null,
"count": 2,
"avgRating": 0,
"downCount": 1
}
]
}

A window containing no feedback returns an empty bucket list:

{ "groupBy": "prompt_version", "buckets": [] }

Error responses

Empty body — none of rating/label/comment supplied (status 400):

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Provide at least one of rating, label, or comment." } }

rating outside -1..5 (status 400):

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 9}'
{ "error": { "code": "VALIDATION_ERROR", "message": "rating must be between -1 and 5." } }

spanId does not belong to this trace (status 400, distinct error code):

curl -X POST $ACRUXCORE_BASE_URL/traces/fbb5f16e-521b-4839-b9ab-78ace8c1f0a4/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1, "spanId": "does-not-exist"}'
{ "error": { "code": "INVALID_SPAN", "message": "spanId does not belong to this trace." } }

Unknown traceId (status 404):

curl -X POST $ACRUXCORE_BASE_URL/traces/00000000-0000-0000-0000-000000000000/feedback \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" -H "Content-Type: application/json" -d '{"rating": 1}'
{ "error": { "code": "NOT_FOUND", "message": "Trace not found." } }

Trace exists but belongs to another team (status 404) — same message as unknown, so the endpoint never confirms whether a foreign trace exists:

{ "error": { "code": "NOT_FOUND", "message": "Trace not found." } }

No Authorization header, on either POST or the summary GET (status 401):

{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }

GET /api/v1/traces/feedback

T10: the team-wide raw feed backing the feedback visualization page — distinct from GET /:id/feedback (one trace) and /feedback/summary (aggregated). Newest-first, paginated (page/limit, limit capped at 100 like other list endpoints). Same row shape as the other feedback endpoints; spanId is the OTel ref (null for whole-trace feedback).

Filters. Every filter on GET /api/v1/traces applies here and describes the feedback row's trace — prompt_id, tags, metadata[key], q, q_in, model, status, session_id, and the score bounds. Four filters are specific to the feedback row itself:

  • ratingup (rating above 0), down (below 0), or none (no rating).
  • sourceuser, developer, end_user, or api.
  • label — exact match.
  • has_commenttrue for rows carrying a written critique, false for rows without one.

One exception to the shared vocabulary: from/to window the feedback row's timestamp, not its trace's, because a critique is often written days after the run it grades. There is no default window here.

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback?limit=2"

Response (status 200) — 3 feedback rows exist across the team's traces; this page shows the 2 newest:

{
"data": [
{
"id": "c80dfe36-cf93-4a0a-9a74-2a32ae2e33d2",
"traceId": "b6f37c03-30f6-4b8a-9082-24c9cbc4f8f4",
"spanId": "s2",
"rating": 1,
"label": null,
"comment": null,
"source": "developer",
"createdBy": "c7f4e80d-7873-445a-92a2-eaadfeb88896",
"createdAt": "2026-07-05T17:11:43.283Z",
"updatedAt": "2026-07-05T17:11:43.283Z"
},
{
"id": "ded66aec-e08f-48f9-93da-fcb45bbb6a87",
"traceId": "b6f37c03-30f6-4b8a-9082-24c9cbc4f8f4",
"spanId": "s2",
"rating": -1,
"label": null,
"comment": "Flight summary was thin",
"source": "developer",
"createdBy": "c7f4e80d-7873-445a-92a2-eaadfeb88896",
"createdAt": "2026-07-05T17:08:20.923Z",
"updatedAt": "2026-07-05T17:08:20.923Z"
}
],
"total": 3,
"page": 1,
"limit": 2
}

Filtering the feed

Thumbs-down rows that carry a written comment — the shape worth turning into evaluation data, since the comment becomes the example's judge criteria:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/feedback?rating=down&has_comment=true&limit=1"

Response (status 200) — total counts the filtered set, not the whole feed:

{
"data": [
{
"id": "5c9baf1d-3a51-4e38-8dab-76de04ebf8c7",
"traceId": "faa16a20-9a25-48b1-9396-e9b4720b0666",
"spanId": null,
"rating": -1,
"label": "test",
"comment": "test",
"source": "developer",
"createdBy": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"author": {
"id": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"name": "Demo",
"email": "demo@acruxcore.com"
},
"createdAt": "2026-09-07T19:24:52.773Z",
"updatedAt": "2026-09-07T19:25:04.214Z"
}
],
"total": 4,
"page": 1,
"limit": 1
}

No Authorization header (status 401):

{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }