feat(web): copy observability debug bundle from dashboard (#1320)

* feat(web): copy observability debug bundle from dashboard

- Add CopyDebugBundleButton (desktop hero) calling GET /api/observability
- Document in docs/observability.md

Made-with: Cursor

* fix(web): harden debug bundle copy safety

Scope copied observability payloads to the selected project, scrub obvious token patterns from copied strings, and avoid copying on failed/non-JSON observability responses. Also avoid leaking query/hash URL data and add tests for failure branches.

Made-with: Cursor
This commit is contained in:
Chirag Arora 2026-04-21 11:35:43 +05:30 committed by GitHub
parent fed25d5d29
commit e518562a62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 232 additions and 1 deletions

View File

@ -0,0 +1,5 @@
---
"@composio/ao-web": patch
---
Add a dashboard control to copy observability diagnostics and page context to the clipboard for support and issue reports.

View File

@ -80,7 +80,7 @@ Health records provide current status and failure context per surface:
## Operator-Facing Diagnostics
- **Dashboard**: observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
- **Dashboard**: use **Copy debug info** in the hero toolbar (desktop) to copy `/api/observability` plus page URL, project scope, and correlation id to the clipboard for issue reports. The observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
- **API**: `/api/observability` returns merged per-project diagnostics (`overallStatus`, metrics, health, recent traces, session state).
- **Terminal websocket health**: `/health` endpoints include active sessions and websocket/terminal health counters with last error/disconnect reasons.

View File

@ -0,0 +1,131 @@
"use client";
import { useCallback, useState } from "react";
import { useToast } from "./Toast";
interface CopyDebugBundleButtonProps {
/** Currently selected project filter, if any (from dashboard URL). */
projectId?: string;
}
const SECRET_PATTERNS = [
/\bBearer\s+[A-Za-z0-9._\-+/=]+\b/gi,
/\bsk-[A-Za-z0-9]{10,}\b/g,
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
];
function redactSecretsInString(value: string): string {
return SECRET_PATTERNS.reduce(
(current, pattern) => current.replace(pattern, "[REDACTED]"),
value,
);
}
function sanitizeForClipboard(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
if (typeof value === "string") {
return redactSecretsInString(value);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeForClipboard(entry, seen));
}
if (value && typeof value === "object") {
if (seen.has(value)) return "[Circular]";
seen.add(value);
const sanitized: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
sanitized[key] = sanitizeForClipboard(entry, seen);
}
return sanitized;
}
return value;
}
function scopeObservabilityToProject(observability: unknown, projectId?: string): unknown {
if (!projectId || !observability || typeof observability !== "object") {
return observability;
}
const scoped = { ...(observability as Record<string, unknown>) };
const projects = scoped["projects"];
if (projects && typeof projects === "object" && !Array.isArray(projects)) {
const projectEntry = (projects as Record<string, unknown>)[projectId];
scoped["projects"] = projectEntry === undefined ? {} : { [projectId]: projectEntry };
}
return scoped;
}
/**
* Copies observability snapshot + page context to the clipboard for GitHub issues / support.
*/
export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) {
const { showToast } = useToast();
const [busy, setBusy] = useState(false);
const handleClick = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const res = await fetch("/api/observability", { credentials: "same-origin" });
if (!res.ok) {
showToast("Could not fetch observability snapshot", "error");
return;
}
const correlationId = res.headers.get("x-correlation-id");
let observabilityRaw: unknown;
try {
observabilityRaw = await res.json();
} catch {
showToast("Could not parse observability snapshot", "error");
return;
}
const observability = sanitizeForClipboard(
scopeObservabilityToProject(observabilityRaw, projectId),
);
const bundle = {
copiedAt: new Date().toISOString(),
pageHref: `${window.location.origin}${window.location.pathname}`,
projectId: projectId ?? null,
correlationId,
userAgent: navigator.userAgent,
observability,
};
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
showToast("Debug bundle copied to clipboard", "success");
} catch {
showToast("Could not copy debug bundle", "error");
} finally {
setBusy(false);
}
}, [busy, projectId, showToast]);
return (
<button
type="button"
className="orchestrator-btn flex min-h-[44px] min-w-[44px] items-center gap-2 px-4 py-2 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:opacity-50"
onClick={() => void handleClick()}
disabled={busy}
aria-label="Copy debug bundle for issue reports"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy debug info
</button>
);
}

View File

@ -20,6 +20,7 @@ import type { ProjectInfo } from "@/lib/project-name";
import { EmptyState } from "./Skeleton";
import { ToastProvider, useToast } from "./Toast";
import { ConnectionBar } from "./ConnectionBar";
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
import { SidebarContext } from "./workspace/SidebarContext";
interface DashboardProps {
@ -489,6 +490,7 @@ function DashboardInner({
Orchestrator
</a>
) : null}
{!isMobile ? <CopyDebugBundleButton projectId={projectId} /> : null}
</div>
</header>

View File

@ -0,0 +1,93 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { ToastProvider } from "../Toast";
import { CopyDebugBundleButton } from "../CopyDebugBundleButton";
const writeText = vi.fn(() => Promise.resolve());
const fetchMock = vi.fn();
function renderButton(projectId?: string) {
return render(
<ToastProvider>
<CopyDebugBundleButton projectId={projectId} />
</ToastProvider>,
);
}
describe("CopyDebugBundleButton", () => {
beforeEach(() => {
writeText.mockClear();
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
headers: { get: (name: string) => (name === "x-correlation-id" ? "corr-test" : null) },
json: () =>
Promise.resolve({
overallStatus: "ok",
projects: {
"my-app": { status: "warn", trace: { reason: "Bearer sk-1234567890 token failed" } },
"other-app": { status: "ok" },
},
}),
} as Response);
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(globalThis.navigator, "clipboard", {
value: { writeText },
configurable: true,
});
window.history.replaceState({}, "", "/?token=secret#frag");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("copies observability JSON and shows success toast", async () => {
renderButton("my-app");
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
await waitFor(() => {
expect(writeText).toHaveBeenCalled();
});
const written = JSON.parse(writeText.mock.calls[0][0] as string);
expect(written.projectId).toBe("my-app");
expect(written.correlationId).toBe("corr-test");
expect(written.pageHref).toMatch(/^http:\/\/localhost(?::\d+)?\/$/);
expect(written.observability.projects).toEqual({
"my-app": { status: "warn", trace: { reason: "[REDACTED] token failed" } },
});
await waitFor(() => {
expect(screen.getByText(/Debug bundle copied to clipboard/i)).toBeInTheDocument();
});
});
it("shows error toast and skips clipboard when observability request fails", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 502,
headers: { get: () => null },
} as Response);
renderButton("my-app");
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
await waitFor(() => {
expect(screen.getByText(/Could not fetch observability snapshot/i)).toBeInTheDocument();
});
expect(writeText).not.toHaveBeenCalled();
});
it("shows error toast when clipboard write fails", async () => {
writeText.mockRejectedValueOnce(new Error("clipboard blocked"));
renderButton("my-app");
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
await waitFor(() => {
expect(screen.getByText(/Could not copy debug bundle/i)).toBeInTheDocument();
});
});
});