Repository navigation
Retain correlated search failure diagnostics #272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c02ebce
fix(search): retain correlated request and failure diagnostics
WilliamAGH 4b62161
fix(search): retain correlated request and failure diagnostics
WilliamAGH 4835027
fix(search): retain correlated request and failure diagnostics
WilliamAGH 5a6d60d
fix(search): retain correlated request and failure diagnostics
WilliamAGH e8ad2ce
fix(search): retain correlated request and failure diagnostics
WilliamAGH 4cf3e38
fix(search): retain correlated request and failure diagnostics
WilliamAGH 9a23ae6
fix(diagnostics): report final request and stream failure outcomes
WilliamAGH 4022003
docs(diagnostics): explain deferred error response completion
WilliamAGH bee5ced
test(diagnostics): require the correlated request header
WilliamAGH File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { expect, test } from "@playwright/test"; | ||
| import { BrowserSearchFailureSchema } from "../src/lib/validation/schemas"; | ||
|
|
||
| for (const failureKind of ["HTTP", "SSE"] as const) { | ||
| test(`${failureKind} search failure reaches diagnostics without replaying the question`, async ({ | ||
| page, | ||
| }) => { | ||
| const searchRequests: string[] = []; | ||
| await page.route("**/api/security/csrf", async (route) => route.fulfill({ status: 204 })); | ||
| await page.route("**/api/diagnostics/search-failures", async (route) => | ||
| route.fulfill({ status: 202 }), | ||
| ); | ||
| await page.route("**/api/chat/stream", async (route) => { | ||
| searchRequests.push(route.request().url()); | ||
| await route.fulfill({ | ||
| status: failureKind === "HTTP" ? 429 : 200, | ||
| headers: { "X-Request-ID": "browser-refusal-test", "Retry-After": "12" }, | ||
| contentType: failureKind === "HTTP" ? "application/json" : "text/event-stream", | ||
| body: | ||
| failureKind === "HTTP" | ||
| ? JSON.stringify({ status: "error", message: "Search is temporarily rate limited" }) | ||
| : 'event:error\ndata:{"message":"Search is temporarily rate limited","code":"test.rate_limited","retryable":true}\n\n', | ||
| }); | ||
| }); | ||
| await page.goto("/"); | ||
| const diagnosticSubmission = page.waitForRequest("**/api/diagnostics/search-failures"); | ||
| await page.getByLabel("Message input").fill("Explain Java records"); | ||
| await page.getByLabel("Message input").press("Enter"); | ||
| await expect( | ||
| page | ||
| .locator(".message.assistant.error .bubble") | ||
| .getByText("Search is temporarily rate limited", { exact: true }), | ||
| ).toBeVisible(); | ||
| const diagnosticRequest = await diagnosticSubmission; | ||
| const parsedDiagnostic: unknown = diagnosticRequest.postDataJSON(); | ||
| const diagnostic = BrowserSearchFailureSchema.safeParse(parsedDiagnostic); | ||
| if (!diagnostic.success) throw diagnostic.error; | ||
| expect(diagnostic.data).toMatchObject({ | ||
| path: "/api/chat/stream", | ||
| kind: failureKind, | ||
| httpStatus: failureKind === "HTTP" ? 429 : 200, | ||
| requestId: "browser-refusal-test", | ||
| }); | ||
| expect(diagnosticRequest.postData()).not.toContain("Explain Java records"); | ||
| expect(searchRequests).toHaveLength(1); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { BrowserSearchFailureSchema } from "../validation/schemas"; | ||
| import { streamSse, streamSseGet } from "./sse"; | ||
|
|
||
| describe("search failure diagnostic transport", () => { | ||
| afterEach(() => { | ||
| window.history.replaceState({}, "", "/"); | ||
| vi.unstubAllGlobals(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it.each([1, 160, 161])("retains HTTP diagnostics at page length %i", async (pagePathLength) => { | ||
| const pagePath = `/${"a".repeat(pagePathLength - 1)}`; | ||
| window.history.replaceState({}, "", `${pagePath}?question=private-query#private-fragment`); | ||
| const submittedReports: string[] = []; | ||
| const searchRequests: string[] = []; | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (url: string, options?: RequestInit) => { | ||
| if (url === "/api/diagnostics/search-failures") { | ||
| if (typeof options?.body !== "string") | ||
| throw new Error("Expected a JSON diagnostic request body"); | ||
| submittedReports.push(options.body); | ||
| return new Response(null, { status: 202 }); | ||
| } | ||
| searchRequests.push(url); | ||
| return new Response(null, { | ||
| status: 429, | ||
| headers: { | ||
| "X-Request-ID": "refusal-report-test", | ||
| "Retry-After": "12", | ||
| }, | ||
| }); | ||
| }), | ||
| ); | ||
|
|
||
| await expect( | ||
| streamSse( | ||
| "/api/chat/stream", | ||
| { latest: "private search question" }, | ||
| { onText: vi.fn() }, | ||
| "chat.ts", | ||
| ), | ||
| ).rejects.toMatchObject({ httpStatus: 429 }); | ||
| await vi.waitFor(() => expect(submittedReports).toHaveLength(1)); | ||
| expect(searchRequests).toEqual(["/api/chat/stream"]); | ||
| expect(submittedReports[0]).not.toContain("private search question"); | ||
| expect(submittedReports[0]).not.toContain("private-query"); | ||
| expect(submittedReports[0]).not.toContain("private-fragment"); | ||
| const diagnostic = BrowserSearchFailureSchema.safeParse( | ||
| JSON.parse(submittedReports[0] ?? "null"), | ||
| ); | ||
| if (!diagnostic.success) throw diagnostic.error; | ||
| expect(diagnostic.data).toMatchObject({ | ||
| kind: "HTTP", | ||
| path: "/api/chat/stream", | ||
| httpStatus: 429, | ||
| requestId: "refusal-report-test", | ||
| retryAfterSeconds: 12, | ||
| pagePath: `/${"a".repeat(Math.min(pagePathLength, 160) - 1)}`, | ||
| }); | ||
| }); | ||
|
|
||
| it.each([ | ||
| { method: "GET", path: "/api/guided/content/stream" }, | ||
| { method: "POST", path: "/api/chat/stream" }, | ||
| ])( | ||
| "reports a missing $method response body as PROTOCOL without replaying the search", | ||
| async ({ method, path }) => { | ||
| const submittedReports: string[] = []; | ||
| const searchRequests: string[] = []; | ||
| const searchUrl = `${path}?slug=java-records`; | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (url: string, options?: RequestInit) => { | ||
| if (url === "/api/diagnostics/search-failures") { | ||
| if (typeof options?.body !== "string") | ||
| throw new Error("Expected a JSON diagnostic request body"); | ||
| submittedReports.push(options.body); | ||
| return new Response(null, { status: 202 }); | ||
| } | ||
| searchRequests.push(url); | ||
| return new Response(null, { | ||
| status: 200, | ||
| headers: { "X-Request-ID": "missing-body-report-test" }, | ||
| }); | ||
| }), | ||
| ); | ||
|
|
||
| const streamingRequest = | ||
| method === "GET" | ||
| ? streamSseGet(searchUrl, { onText: vi.fn() }, "guided.lesson-content.ts") | ||
| : streamSse( | ||
| searchUrl, | ||
| { latest: "private search question" }, | ||
| { onText: vi.fn() }, | ||
| "chat.ts", | ||
| ); | ||
| await expect(streamingRequest).rejects.toMatchObject({ | ||
| name: "StreamFailureError", | ||
| message: "No response body", | ||
| retryable: true, | ||
| }); | ||
| await vi.waitFor(() => expect(submittedReports).toHaveLength(1)); | ||
| expect(searchRequests).toEqual([searchUrl]); | ||
| expect(submittedReports[0]).not.toContain("private search question"); | ||
| expect(submittedReports[0]).not.toContain("java-records"); | ||
| const diagnostic = BrowserSearchFailureSchema.safeParse( | ||
| JSON.parse(submittedReports[0] ?? "null"), | ||
| ); | ||
| if (!diagnostic.success) throw diagnostic.error; | ||
| expect(diagnostic.data).toMatchObject({ | ||
| kind: "PROTOCOL", | ||
| path, | ||
| httpStatus: 200, | ||
| requestId: "missing-body-report-test", | ||
| retryable: true, | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| it("distinguishes an SSE error inside HTTP 200 from an HTTP refusal", async () => { | ||
| const submittedReports: string[] = []; | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (url: string, options?: RequestInit) => { | ||
| if (url === "/api/diagnostics/search-failures") { | ||
| if (typeof options?.body !== "string") | ||
| throw new Error("Expected a JSON diagnostic request body"); | ||
| submittedReports.push(options.body); | ||
| return new Response(null, { status: 202 }); | ||
| } | ||
| return new Response( | ||
| 'event:error\ndata:{"message":"Temporarily unavailable","code":"stream.provider_retryable_error","retryable":true}\n\n', | ||
| { status: 200, headers: { "X-Request-ID": "sse-report-test" } }, | ||
| ); | ||
| }), | ||
| ); | ||
| await expect( | ||
| streamSse( | ||
| "/api/guided/stream", | ||
| { latest: "private question" }, | ||
| { onText: vi.fn() }, | ||
| "guided.ts", | ||
| ), | ||
| ).rejects.toThrow("Temporarily unavailable"); | ||
| await vi.waitFor(() => expect(submittedReports).toHaveLength(1)); | ||
| expect(JSON.parse(submittedReports[0] ?? "null")).toMatchObject({ | ||
| kind: "SSE", | ||
| httpStatus: 200, | ||
| requestId: "sse-report-test", | ||
| code: "stream.provider_retryable_error", | ||
| }); | ||
| }); | ||
|
|
||
| it("does not recursively report an unavailable diagnostic endpoint", async () => { | ||
| const requestedPaths: string[] = []; | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (url: string) => { | ||
| requestedPaths.push(url); | ||
| throw new TypeError("Connection unavailable"); | ||
| }), | ||
| ); | ||
| vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| await expect( | ||
| streamSse("/api/chat/stream", { latest: "question" }, { onText: vi.fn() }, "chat.ts"), | ||
| ).rejects.toThrow("Couldn't reach the server"); | ||
| await vi.waitFor(() => | ||
| expect(requestedPaths).toEqual(["/api/chat/stream", "/api/diagnostics/search-failures"]), | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { BrowserSearchFailureSchema, type BrowserSearchFailure } from "../validation/schemas"; | ||
| import { validateWithSchema } from "../validation/validate"; | ||
| import { fetchWithCsrfRetry } from "./csrf"; | ||
|
|
||
| /** Sends one browser observation into the existing application log transport, without replaying searches. */ | ||
| export async function reportSearchFailure(failure: BrowserSearchFailure): Promise<void> { | ||
| const diagnosticValidation = validateWithSchema( | ||
| BrowserSearchFailureSchema, | ||
| failure, | ||
| "search-diagnostics", | ||
| ); | ||
| if (!diagnosticValidation.success) { | ||
| return; | ||
| } | ||
| try { | ||
| const diagnosticReply = await fetchWithCsrfRetry( | ||
| "/api/diagnostics/search-failures", | ||
| { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(diagnosticValidation.validated), | ||
| keepalive: true, | ||
| }, | ||
| "search-diagnostics", | ||
| ); | ||
| if (!diagnosticReply.ok) { | ||
| console.warn("Search diagnostics were rejected", { status: diagnosticReply.status }); | ||
| } | ||
| } catch (diagnosticFailure) { | ||
| // A failed report must neither replace the search's original error nor recursively report itself. | ||
| console.warn("Search diagnostics could not reach the server", diagnosticFailure); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.