From f11cb623c4cf08ca84def5b0b9eae8a48e12b5c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 18:20:11 +0000 Subject: [PATCH 1/4] fix(sentry): set summarize conversation id before LLM work Wire setAgentConversationId on document summarize, document the Agent Monitoring wizard mapping, and keep inputs/outputs/user off. --- docs/error-tracking.md | 17 +++- src/app/api/documents/[id]/summarize/route.ts | 6 +- tests/summarize-agent-conversation.test.ts | 95 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/summarize-agent-conversation.test.ts diff --git a/docs/error-tracking.md b/docs/error-tracking.md index a4972603b4..7cef642936 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -31,12 +31,27 @@ Privacy constraints for agent monitoring: - `recordInputs` / `recordOutputs` are **false** on the wrap and `dataCollection.genAI` is `{ inputs: false, outputs: false }` in both runtime configs — prompts, clinical queries, source evidence, generated answers, and embedding inputs are never recorded. - `privacySafeTransactionEvent` allowlists only gen_ai metadata attributes (system, operation name, request/response model, response id, finish reasons, token usage, conversation id) and rebuilds gen_ai span descriptions as ` ` from those attributes. Message, prompt, tool-payload, and embedding-input attributes are stripped on export even if a future SDK version records them. -- Each answer request (`/api/answer` and `/api/answer/stream`, summaries included) calls `Sentry.setConversationId()` — the request's synthetic UUID — so the embedding/generation calls of one request group into one conversation without carrying any query text. +- Each answer / stream / document-summarize request calls `Sentry.setConversationId()` **before** OpenAI work — the request's synthetic UUID — so the embedding/generation calls of one request group into one conversation without carrying any query text. - User identification (`Sentry.setUser`) is deliberately **not** wired: the committed privacy boundary strips `user` from every outgoing event (see the tests), and linking clinical-query telemetry to an identity would need its own governance review first. - `responses.parse` (schema-parsed generation) is not in the SDK's instrumentation registry and emits no gen_ai span; `responses.create` and `embeddings.create` are covered. Rollback matches tracing: set `SENTRY_TRACES_SAMPLE_RATE=0` (agent spans stop; error capture stays) or remove `SENTRY_DSN`. Raising the sample rate above the 0.1 default captures a larger share of answer requests in the agents view and is an operator decision. +### Sentry “Agent Monitoring” wizard mapping + +The product wizard’s copy is generic. Map it to this repo as follows — do **not** paste the wizard’s sample code: + +| Wizard step | This repo | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Install `@sentry/nextjs` ≥ 10.67 | npm dependency (`^10.67.0`, lock ≥ 10.67); do not switch to pnpm | +| `Sentry.init` + tracing | `src/sentry.server.config.ts` / `src/sentry.edge.config.ts`; DSN from `SENTRY_DSN` only (never hardcode); default `tracesSampleRate` 0.1 via `SENTRY_TRACES_SAMPLE_RATE` | +| `dataCollection.genAI` | Explicitly `{ inputs: false, outputs: false }` | +| `instrumentOpenAiClient` | `instrumentOpenAIClientForAgentMonitoring` in `createOpenAIClient()` — `recordInputs`/`recordOutputs` **false** | +| `setConversationId` | `setAgentConversationId(interactionId)` on `/api/answer`, `/api/answer/stream`, and `/api/documents/[id]/summarize` | +| `setUser` (optional) | **Rejected** — scrubbers strip `user`; do not wire | + +Leaving wizard checkboxes for “record inputs/outputs” or “identify users” unchecked is expected and correct for this clinical app. + ## Operator approval and rollout Before setting `SENTRY_DSN`, the operator must approve the vendor/project, data region, retention period, access roles, sampling rate, cost budget, and alert destination. Configure a server-side DSN only; never use a `NEXT_PUBLIC_*` DSN. Keep provider-side IP/user enrichment disabled and restrict project access. Start with a non-production synthetic exception and inspect the received event before enabling production alerts. diff --git a/src/app/api/documents/[id]/summarize/route.ts b/src/app/api/documents/[id]/summarize/route.ts index e2e0fc7564..25e3f90434 100644 --- a/src/app/api/documents/[id]/summarize/route.ts +++ b/src/app/api/documents/[id]/summarize/route.ts @@ -10,6 +10,7 @@ import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { answerFeedbackMetadata } from "@/lib/answer-feedback-token"; import { jsonError } from "@/lib/http"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { setAgentConversationId } from "@/lib/observability/agent-monitoring"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRouteParams } from "@/lib/validation/params"; @@ -39,6 +40,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "document_summarize" }); if (rateLimit.limited) return rateLimitJsonResponse("Too many document summary requests. Retry shortly.", rateLimit); + // Group this request's LLM calls into one Sentry agent-monitoring conversation + // before any OpenAI work starts. Synthetic UUID only — never document/query text. + const interactionId = randomUUID(); + setAgentConversationId(interactionId); const answer = await summarizeDocument(id, user.id, { signal: request.signal }); const governedResponse = buildGovernedAnswerClientResponse(answer); logAnswerDiagnostics({ @@ -47,7 +52,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: ownerId: user.id, answer: governedResponse.telemetryAnswer, }); - const interactionId = randomUUID(); return NextResponse.json({ ...governedResponse.payload, ...answerFeedbackMetadata(interactionId, governedResponse.payload.answer), diff --git a/tests/summarize-agent-conversation.test.ts b/tests/summarize-agent-conversation.test.ts new file mode 100644 index 0000000000..7c0ee0be99 --- /dev/null +++ b/tests/summarize-agent-conversation.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const setAgentConversationId = vi.fn(); +const summarizeDocument = vi.fn(async () => ({ + answer: "Summary body.", + grounded: true, + confidence: "high", + citations: [], + smartPanel: { query: "summary" }, + smartApiPlan: { displayMode: "direct" }, +})); + +vi.mock("@/lib/observability/agent-monitoring", () => ({ + setAgentConversationId: (...args: unknown[]) => setAgentConversationId(...args), +})); + +vi.mock("@/lib/demo-data", () => ({ + isDemoMode: () => false, + getDemoDocument: () => null, + demoSummary: () => ({}), +})); + +vi.mock("@/lib/env", () => ({ + isDemoMode: () => false, +})); + +vi.mock("@/lib/rag/rag", () => ({ + summarizeDocument: (...args: unknown[]) => summarizeDocument(...args), +})); + +vi.mock("@/lib/answer-response", () => ({ + buildGovernedAnswerClientResponse: (answer: { answer: string }) => ({ + payload: { answer: answer.answer, grounded: true }, + telemetryAnswer: answer, + }), + buildGovernedDemoAnswerClientResponse: (payload: unknown) => payload, +})); + +vi.mock("@/lib/answer-telemetry", () => ({ + logAnswerDiagnostics: vi.fn(), +})); + +vi.mock("@/lib/answer-feedback-token", () => ({ + answerFeedbackMetadata: (interactionId: string) => ({ interactionId }), +})); + +vi.mock("@/lib/api-rate-limit", () => ({ + consumeApiRateLimit: vi.fn(async () => ({ + limited: false, + limit: 12, + remaining: 11, + retryAfterSeconds: 60, + resetAt: new Date().toISOString(), + })), + rateLimitJsonResponse: vi.fn(), +})); + +vi.mock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({}), +})); + +vi.mock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => ({ id: "11111111-1111-4111-8111-111111111111" })), + unauthorizedResponse: () => new Response(null, { status: 401 }), +})); + +describe("document summarize Sentry conversation id", () => { + afterEach(() => { + setAgentConversationId.mockClear(); + summarizeDocument.mockClear(); + vi.resetModules(); + }); + + it("sets the agent conversation id before any LLM summarize work", async () => { + const { POST } = await import("../src/app/api/documents/[id]/summarize/route"); + const documentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + + const response = await POST( + new Request(`http://localhost/api/documents/${documentId}/summarize`, { method: "POST" }), + { + params: Promise.resolve({ id: documentId }), + }, + ); + + expect(response.status).toBe(200); + expect(setAgentConversationId).toHaveBeenCalledTimes(1); + expect(setAgentConversationId.mock.invocationCallOrder[0]).toBeLessThan( + summarizeDocument.mock.invocationCallOrder[0]!, + ); + const body = (await response.json()) as { interactionId?: string }; + expect(body.interactionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(setAgentConversationId).toHaveBeenCalledWith(body.interactionId); + }); +}); From bdfd7a407aacf2a4396f7940b706dbcd0da3e94c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 18:29:37 +0000 Subject: [PATCH 2/4] fix(test): unbreak summarize agent-conversation mock types Drop unknown rest spreads that failed tsc TS2556 on Static PR checks. --- tests/summarize-agent-conversation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/summarize-agent-conversation.test.ts b/tests/summarize-agent-conversation.test.ts index 7c0ee0be99..98ff029716 100644 --- a/tests/summarize-agent-conversation.test.ts +++ b/tests/summarize-agent-conversation.test.ts @@ -11,7 +11,7 @@ const summarizeDocument = vi.fn(async () => ({ })); vi.mock("@/lib/observability/agent-monitoring", () => ({ - setAgentConversationId: (...args: unknown[]) => setAgentConversationId(...args), + setAgentConversationId, })); vi.mock("@/lib/demo-data", () => ({ @@ -25,7 +25,7 @@ vi.mock("@/lib/env", () => ({ })); vi.mock("@/lib/rag/rag", () => ({ - summarizeDocument: (...args: unknown[]) => summarizeDocument(...args), + summarizeDocument, })); vi.mock("@/lib/answer-response", () => ({ From ed29cf2b2c1360b6c14ff61c86417ac80d1f3724 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:42:23 +0800 Subject: [PATCH 3/4] docs(sentry): align Agent Monitoring wizard dependency version --- docs/error-tracking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/error-tracking.md b/docs/error-tracking.md index 28a3132135..5df4159462 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -43,7 +43,7 @@ The product wizard’s copy is generic. Map it to this repo as follows — do ** | Wizard step | This repo | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Install `@sentry/nextjs` ≥ 10.67 | npm dependency (`^10.67.0`, lock ≥ 10.67); do not switch to pnpm | +| Install `@sentry/nextjs` ≥ 10.67 | repository dependency `@sentry/nextjs@^10.69.0` (lockfile resolves `10.69.0`); `10.67` is the minimum supported version; do not switch to pnpm | | `Sentry.init` + tracing | `src/sentry.server.config.ts` / `src/sentry.edge.config.ts`; DSN from `SENTRY_DSN` only (never hardcode); default `tracesSampleRate` 0.1 via `SENTRY_TRACES_SAMPLE_RATE` | | `dataCollection.genAI` | Explicitly `{ inputs: false, outputs: false }` | | `instrumentOpenAiClient` | `instrumentOpenAIClientForAgentMonitoring` in `createOpenAIClient()` — `recordInputs`/`recordOutputs` **false** | From a61470c6bac8d1b410a529d248c7bbfbe946c0fc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:45:25 +0800 Subject: [PATCH 4/4] style: format docs/error-tracking.md --- docs/error-tracking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/error-tracking.md b/docs/error-tracking.md index 5df4159462..1bd417d768 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -43,7 +43,7 @@ The product wizard’s copy is generic. Map it to this repo as follows — do ** | Wizard step | This repo | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Install `@sentry/nextjs` ≥ 10.67 | repository dependency `@sentry/nextjs@^10.69.0` (lockfile resolves `10.69.0`); `10.67` is the minimum supported version; do not switch to pnpm | +| Install `@sentry/nextjs` ≥ 10.67 | repository dependency `@sentry/nextjs@^10.69.0` (lockfile resolves `10.69.0`); `10.67` is the minimum supported version; do not switch to pnpm | | `Sentry.init` + tracing | `src/sentry.server.config.ts` / `src/sentry.edge.config.ts`; DSN from `SENTRY_DSN` only (never hardcode); default `tracesSampleRate` 0.1 via `SENTRY_TRACES_SAMPLE_RATE` | | `dataCollection.genAI` | Explicitly `{ inputs: false, outputs: false }` | | `instrumentOpenAiClient` | `instrumentOpenAIClientForAgentMonitoring` in `createOpenAIClient()` — `recordInputs`/`recordOutputs` **false** |