-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add renderer diagnostics observability #392
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 14 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
bb49aaa
feat: add renderer diagnostics recorder
Astro-Han 5b9eb62
feat: expose renderer diagnostics IPC
Astro-Han 0d49983
feat: wire renderer diagnostics export
Astro-Han 1bf8744
feat: emit session renderer diagnostics
Astro-Han 91e2928
feat: attach renderer diagnostics to exports
Astro-Han 521740a
test: add renderer diagnostics e2e coverage
Astro-Han dc1a25b
fix: harden renderer diagnostics export
Astro-Han b1f6642
Merge remote-tracking branch 'origin/dev' into codex/i389-renderer-di…
Astro-Han f39c1f7
fix: reduce renderer diagnostics false positives
Astro-Han a7f1aec
fix: export renderer diagnostics as json
Astro-Han 1373c4b
fix: harden renderer diagnostics review issues
Astro-Han 39b4ec6
fix: address renderer diagnostics review gaps
Astro-Han ec54e50
fix: address diagnostics review followups
Astro-Han a392526
fix: make diagnostics instrumentation fail open
Astro-Han 45e1c2b
fix: recover renderer diagnostics after transient failures
Astro-Han 70f8cfc
fix: drain renderer diagnostics before export
Astro-Han 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
178 changes: 178 additions & 0 deletions
178
packages/app/e2e/session/session-renderer-diagnostics.spec.ts
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,178 @@ | ||
| import type { Page } from "@playwright/test" | ||
| import { test, expect } from "../fixtures" | ||
| import { withSession } from "../actions" | ||
| import { | ||
| promptSelector, | ||
| scrollViewportSelector, | ||
| sessionMessageItemSelector, | ||
| sessionTurnListSelector, | ||
| } from "../selectors" | ||
| import { createSdk } from "../utils" | ||
|
|
||
| type Sdk = ReturnType<typeof createSdk> | ||
|
|
||
| type CapturedDiagnosticEvent = { | ||
| name: string | ||
| route_session_id?: string | ||
| visible_session_id?: string | ||
| timeline_session_id?: string | ||
| trace_id?: string | ||
| data?: Record<string, unknown> | ||
| } | ||
|
|
||
| type TimelineMetrics = { | ||
| top: number | ||
| height: number | ||
| client: number | ||
| distanceFromBottom: number | ||
| } | ||
|
|
||
| async function installRendererDiagnosticsCapture(page: Page) { | ||
| await page.addInitScript(() => { | ||
| const win = window as typeof window & { | ||
| __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] | ||
| api?: { | ||
| emitRendererDiagnostic?: (event: CapturedDiagnosticEvent) => Promise<void> | ||
| } | ||
| } | ||
| win.__pawwork_renderer_diagnostics = [] | ||
| const originalEmit = win.api?.emitRendererDiagnostic?.bind(win.api) | ||
| win.api = { | ||
| ...(win.api ?? {}), | ||
| emitRendererDiagnostic: async (event) => { | ||
| win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event))) | ||
| await originalEmit?.(event) | ||
| }, | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| async function readRendererDiagnostics(page: Page) { | ||
| return page.evaluate(() => { | ||
| const win = window as typeof window & { | ||
| __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] | ||
| } | ||
| return win.__pawwork_renderer_diagnostics ?? [] | ||
| }) as Promise<CapturedDiagnosticEvent[]> | ||
| } | ||
|
|
||
| async function seedSessionTurns(input: { sdk: Sdk; sessionID: string; count: number }) { | ||
| for (let i = 0; i < input.count; i++) { | ||
| await input.sdk.session.promptAsync({ | ||
| sessionID: input.sessionID, | ||
| noReply: true, | ||
| parts: [ | ||
| { | ||
| type: "text", | ||
| text: `diagnostics seed ${i}\n${Array.from({ length: 16 }, (_, line) => `line ${line} ${"content ".repeat(8)}`).join("\n")}`, | ||
| }, | ||
| ], | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| function timelineMetrics(page: Page) { | ||
| return page.evaluate( | ||
| ({ scrollViewportSelector, turnListSelector }) => { | ||
| const list = document.querySelector(turnListSelector) | ||
| const viewport = list?.closest(scrollViewportSelector) | ||
| if (!(viewport instanceof HTMLElement)) return null | ||
| return { | ||
| top: viewport.scrollTop, | ||
| height: viewport.scrollHeight, | ||
| client: viewport.clientHeight, | ||
| distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, | ||
| } | ||
| }, | ||
| { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, | ||
| ) as Promise<TimelineMetrics | null> | ||
| } | ||
|
|
||
| async function expectTimelineMetrics(page: Page) { | ||
| const metrics = await timelineMetrics(page) | ||
| expect(metrics, "session timeline viewport should exist").not.toBeNull() | ||
| return metrics! | ||
| } | ||
|
|
||
| async function scrollTimelineToBottom(page: Page) { | ||
| const found = await page.evaluate( | ||
| ({ scrollViewportSelector, turnListSelector }) => { | ||
| const list = document.querySelector(turnListSelector) | ||
| const viewport = list?.closest(scrollViewportSelector) | ||
| if (!(viewport instanceof HTMLElement)) return false | ||
| viewport.scrollTop = viewport.scrollHeight | ||
| viewport.dispatchEvent(new Event("scroll", { bubbles: true })) | ||
| return true | ||
| }, | ||
| { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, | ||
| ) | ||
| expect(found, "session timeline viewport should exist").toBe(true) | ||
| } | ||
|
|
||
| async function sendVisiblePrompt(input: { page: Page; text: string }) { | ||
| const prompt = input.page.locator(promptSelector) | ||
| await expect(prompt).toBeVisible() | ||
| await prompt.click() | ||
| await input.page.keyboard.insertText(input.text) | ||
| await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(input.text) | ||
| await input.page.keyboard.press("Enter") | ||
| } | ||
|
|
||
| function numberData(event: CapturedDiagnosticEvent, key: string) { | ||
| const value = event.data?.[key] | ||
| return typeof value === "number" && Number.isFinite(value) ? value : undefined | ||
| } | ||
|
|
||
| test("captures renderer diagnostics while guarding send scroll position", async ({ page, project }) => { | ||
| test.setTimeout(120_000) | ||
|
|
||
| await installRendererDiagnosticsCapture(page) | ||
| await project.open() | ||
| const sdk = project.sdk | ||
|
|
||
| await withSession(sdk, `e2e renderer diagnostics ${Date.now()}`, async (session) => { | ||
| project.trackSession(session.id) | ||
| await seedSessionTurns({ sdk, sessionID: session.id, count: 18 }) | ||
|
|
||
| await project.gotoSession(session.id) | ||
| await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 }) | ||
| await scrollTimelineToBottom(page) | ||
| await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(40) | ||
|
|
||
| const scrollAnchorBefore = await page | ||
| .locator(sessionMessageItemSelector) | ||
| .last() | ||
| .evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null)) | ||
| const metricsBefore = await expectTimelineMetrics(page) | ||
| const beforeCount = await page.locator(sessionMessageItemSelector).count() | ||
|
|
||
| await sendVisiblePrompt({ page, text: `diagnostics guard ${Date.now()}` }) | ||
| await expect(page.locator(sessionMessageItemSelector)).toHaveCount(beforeCount + 1, { timeout: 30_000 }) | ||
| await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80) | ||
|
|
||
| const metricsAfter = await expectTimelineMetrics(page) | ||
| const scrollAnchorAfter = await page | ||
| .locator(sessionMessageItemSelector) | ||
| .nth(beforeCount - 1) | ||
| .evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null)) | ||
| expect(scrollAnchorBefore).not.toBeNull() | ||
| expect(scrollAnchorAfter).toBe(scrollAnchorBefore) | ||
| expect(Math.abs(metricsAfter.top - metricsBefore.top)).toBeLessThan(200) | ||
|
|
||
| const events = await readRendererDiagnostics(page) | ||
| expect(events.some((event) => event.name === "session.action.submit")).toBe(true) | ||
| expect(events.some((event) => event.name === "session.timeline.mount")).toBe(true) | ||
| expect(events.some((event) => event.name === "session.timeline.visible")).toBe(true) | ||
| expect(events.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1) | ||
| expect(events.filter((event) => event.name === "session.timeline.unmount")).toHaveLength(0) | ||
| expect(events.filter((event) => event.name.startsWith("incident."))).toEqual([]) | ||
|
|
||
| const visibleCounts = events | ||
| .filter((event) => event.name === "session.timeline.visible") | ||
| .map((event) => numberData(event, "rendered_count") ?? 0) | ||
| expect(Math.min(...visibleCounts)).toBeGreaterThan(0) | ||
| expect( | ||
| events.some((event) => event.name === "session.scroll.sample" && event.data?.user_scrolled === false), | ||
| ).toBe(true) | ||
| }) | ||
| }) | ||
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
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.