diff --git a/apps/api/src/lib/issue-export-load.ts b/apps/api/src/lib/issue-export-load.ts new file mode 100644 index 00000000..21462c64 --- /dev/null +++ b/apps/api/src/lib/issue-export-load.ts @@ -0,0 +1,259 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; +import { + buildErrorOccurrenceScopeWhere, + fetchImpactMetricsForGroupId, + fetchScopedOccurrenceSummaryForGroupId, + listScopedOccurrenceIdsForGroupId, +} from "./errors-list-query.js"; +import { + enrichErrorListFilterForMetrics, + parseErrorsMetricsAnchor, +} from "./errors-page-summary.js"; +import { parseCreatedRange } from "./list-query.js"; +import { resolveUnselectedMetricsWindow } from "./overview-metrics-window.js"; +import { loadProjectPiiDenyKeys } from "./project-pii-scrub-cache.js"; +import { whereErrorGroupById } from "./prisma-project-scope.js"; +import { dashboardOriginOrNull } from "./dashboard-origin.js"; +import { resolveApiVersion } from "./health.js"; +import { + buildIssueExportDocument, + type IssueExportV1, +} from "./issue-export.js"; + +export type ErrorIssueExportQuery = { + app?: string; + environment?: string; + platform?: string; + release?: string; + range?: string; + from?: string; + to?: string; + metricsUntil?: string; + metricsSince?: string; +}; + +function parseIsoDate(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +/** + * Resolve occurrence scope for error detail / export (same rules as GET /errors/:id). + */ +export async function resolveErrorIssueOccurrenceScope( + prisma: PrismaClient, + projectId: string, + errorGroupId: string, + query: ErrorIssueExportQuery +): Promise<{ + occurrenceScope: { + platform?: string; + release?: string; + gte?: Date; + lte?: Date; + }; + occurrenceWhere: Prisma.ErrorOccurrenceWhereInput; + applyScopedMetrics: boolean; +}> { + const environment = query.environment?.trim() || undefined; + const platform = query.platform?.trim() || undefined; + const release = query.release?.trim() || undefined; + const rangeKey = query.range?.trim() || undefined; + const metricsUntilRaw = query.metricsUntil?.trim() || undefined; + const metricsSinceRaw = query.metricsSince?.trim() || undefined; + const appFilter = query.app?.trim() || undefined; + const hasFromTo = Boolean(query.from?.trim() || query.to?.trim()); + + const explicitMetricsSince = parseIsoDate(metricsSinceRaw); + const explicitMetricsUntil = parseIsoDate(metricsUntilRaw); + const explicitOverviewWindow = + explicitMetricsSince && + explicitMetricsUntil && + explicitMetricsSince.getTime() < explicitMetricsUntil.getTime() + ? { gte: explicitMetricsSince, lte: explicitMetricsUntil } + : undefined; + const useIssuesMetricsWindow = + Boolean(metricsUntilRaw) && !explicitOverviewWindow; + const isOverviewUnselected = + !hasFromTo && + (rangeKey === "none" || rangeKey === "all") && + !useIssuesMetricsWindow && + !explicitOverviewWindow; + const hasBoundedPreset = + hasFromTo || Boolean(rangeKey && rangeKey !== "all" && rangeKey !== "none"); + const listRange = parseCreatedRange( + { + range: query.range, + from: query.from, + to: query.to, + }, + "all" + ); + const metricsAnchor = parseErrorsMetricsAnchor(metricsUntilRaw); + + let windowGte: Date | undefined; + let windowLte: Date | undefined; + if (explicitOverviewWindow) { + windowGte = explicitOverviewWindow.gte; + windowLte = explicitOverviewWindow.lte; + } else if (isOverviewUnselected) { + const metricsWindow = await resolveUnselectedMetricsWindow(prisma, { + projectId, + until: metricsAnchor, + app: appFilter, + environment, + platform, + release, + }); + windowGte = metricsWindow.gte; + windowLte = metricsWindow.lte; + } else if (hasBoundedPreset) { + windowGte = listRange.gte; + windowLte = listRange.lte; + } else if (platform || release || useIssuesMetricsWindow) { + const enriched = enrichErrorListFilterForMetrics( + { + range: listRange, + status: "all", + ...(platform ? { platform } : {}), + ...(release ? { release } : {}), + }, + listRange, + metricsAnchor + ); + windowGte = enriched.range.gte ?? enriched.occurrenceCountRange?.gte; + windowLte = enriched.range.lte ?? enriched.occurrenceCountRange?.lte; + } + + const occurrenceScope = { + ...(platform ? { platform } : {}), + ...(release ? { release } : {}), + ...(windowGte ? { gte: windowGte } : {}), + ...(windowLte ? { lte: windowLte } : {}), + }; + const applyScopedMetrics = Boolean( + platform || release || windowGte || windowLte + ); + + const occurrenceScopeFilter = { + ...(platform ? { platform } : {}), + ...(release ? { release } : {}), + ...(windowGte ? { gte: windowGte } : {}), + ...(windowLte ? { lte: windowLte } : {}), + }; + const occurrenceWhere: Prisma.ErrorOccurrenceWhereInput = release + ? { + id: { + in: await listScopedOccurrenceIdsForGroupId( + prisma, + errorGroupId, + projectId, + occurrenceScopeFilter, + 1 + ), + }, + } + : buildErrorOccurrenceScopeWhere(occurrenceScopeFilter); + + return { occurrenceScope, occurrenceWhere, applyScopedMetrics }; +} + +/** Load and build a scrubbed issue export document, or null if not found. */ +export async function loadIssueExportDocument( + prisma: PrismaClient, + projectId: string, + errorGroupId: string, + query: ErrorIssueExportQuery = {} +): Promise { + const [project, scope] = await Promise.all([ + prisma.project.findFirst({ + where: { id: projectId }, + select: { name: true }, + }), + resolveErrorIssueOccurrenceScope(prisma, projectId, errorGroupId, query), + ]); + if (!project) return null; + + const hasOccurrenceFilter = Object.keys(scope.occurrenceWhere).length > 0; + const group = await prisma.errorGroup.findFirst({ + where: whereErrorGroupById(errorGroupId, projectId), + include: { + occurrences_list: { + where: hasOccurrenceFilter ? scope.occurrenceWhere : undefined, + orderBy: { created_at: "desc" }, + take: 1, + }, + }, + }); + if (!group) return null; + + const { enrichErrorGroupWithSymbolicatedStacks } = await import( + "./stack-symbolicate.js" + ); + const [enriched, impact, scopedSummary, denyKeys] = await Promise.all([ + enrichErrorGroupWithSymbolicatedStacks(prisma, projectId, group), + fetchImpactMetricsForGroupId(prisma, errorGroupId, scope.occurrenceScope), + scope.applyScopedMetrics + ? fetchScopedOccurrenceSummaryForGroupId( + prisma, + errorGroupId, + scope.occurrenceScope + ) + : Promise.resolve(null), + loadProjectPiiDenyKeys(prisma, projectId), + ]); + + const latest = enriched.occurrences_list[0] ?? null; + const origin = dashboardOriginOrNull(); + const dashboardUrl = origin + ? `${origin}/dashboard/errors/${encodeURIComponent(errorGroupId)}` + : null; + const symbolicatedTop = + "symbolicated_top_stack" in enriched && + typeof enriched.symbolicated_top_stack === "string" + ? enriched.symbolicated_top_stack + : null; + + return buildIssueExportDocument({ + telemetryTrackerVersion: resolveApiVersion(), + projectName: project.name, + issue: { + id: enriched.id, + message: enriched.message, + fingerprint: enriched.fingerprint, + app: enriched.app, + environment: enriched.environment, + platform: enriched.platform, + release: enriched.release, + occurrences: scopedSummary?.occurrences ?? enriched.occurrences, + users_affected: impact.users_affected, + sessions_affected: impact.sessions_affected, + first_seen: scopedSummary ? scopedSummary.first_seen : enriched.first_seen, + last_seen: scopedSummary ? scopedSummary.last_seen : enriched.last_seen, + resolved_at: enriched.resolved_at, + top_stack: enriched.top_stack, + symbolicated_top_stack: symbolicatedTop, + tags: {}, + }, + latestOccurrence: latest + ? { + id: latest.id, + created_at: latest.created_at, + stack: latest.stack, + symbolicated_stack: + "symbolicated_stack" in latest && + typeof latest.symbolicated_stack === "string" + ? latest.symbolicated_stack + : null, + context: latest.context, + user_id: latest.user_id, + session_id: latest.session_id, + release: latest.release, + sdk_version: latest.sdk_version, + } + : null, + dashboardUrl, + scrubOptions: denyKeys.length > 0 ? { denyKeys } : undefined, + }); +} diff --git a/apps/api/src/lib/issue-export.test.ts b/apps/api/src/lib/issue-export.test.ts new file mode 100644 index 00000000..dd029e39 --- /dev/null +++ b/apps/api/src/lib/issue-export.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + ISSUE_EXPORT_SCHEMA, + buildIssueExportDocument, + issueExportToMarkdown, + type IssueExportSource, +} from "./issue-export.js"; + +function baseSource( + overrides: Partial = {} +): IssueExportSource { + return { + telemetryTrackerVersion: "1.17.3", + exportedAt: new Date("2026-07-19T12:00:00.000Z"), + projectName: "production-api", + issue: { + id: "err-1", + message: "TypeError: boom for user@example.com", + fingerprint: "fp-1", + app: "web", + environment: "production", + platform: "javascript", + release: "1.2.3", + occurrences: 42, + users_affected: 12, + sessions_affected: 18, + first_seen: new Date("2026-07-01T00:00:00.000Z"), + last_seen: new Date("2026-07-19T11:00:00.000Z"), + resolved_at: null, + top_stack: "at foo (app.js:1:1)", + symbolicated_top_stack: "at foo (src/app.ts:10:5)", + tags: {}, + }, + latestOccurrence: { + id: "occ-1", + created_at: new Date("2026-07-19T11:00:00.000Z"), + stack: "Error: boom\n at foo", + symbolicated_stack: "Error: boom\n at foo (src/app.ts:10:5)", + context: { email: "user@example.com", path: "/checkout" }, + user_id: "u-1", + session_id: "s-1", + release: "1.2.3", + sdk_version: "0.9.0", + }, + dashboardUrl: "https://app.example.com/dashboard/errors/err-1", + ...overrides, + }; +} + +describe("buildIssueExportDocument", () => { + it("builds a versioned telemetry-tracker.issue.v1 document", () => { + const doc = buildIssueExportDocument(baseSource()); + expect(doc.schema).toBe(ISSUE_EXPORT_SCHEMA); + expect(doc.telemetry_tracker_version).toBe("1.17.3"); + expect(doc.exported_at).toBe("2026-07-19T12:00:00.000Z"); + expect(doc.project).toEqual({ name: "production-api" }); + expect(doc.project).not.toHaveProperty("id"); + expect(doc.issue.id).toBe("err-1"); + expect(doc.issue.tags).toEqual({}); + expect(doc.latest_occurrence?.id).toBe("occ-1"); + expect(doc.dashboard_url).toContain("/dashboard/errors/err-1"); + }); + + it("allows latest_occurrence to be null", () => { + const doc = buildIssueExportDocument( + baseSource({ latestOccurrence: null }) + ); + expect(doc.latest_occurrence).toBeNull(); + expect(doc.issue.message).toContain("[email]"); + }); + + it("scrubs PII in message, stacks, and context", () => { + const doc = buildIssueExportDocument(baseSource()); + expect(doc.issue.message).toBe("TypeError: boom for [email]"); + expect(doc.latest_occurrence?.context).toMatchObject({ + email: "[email]", + path: "/checkout", + }); + }); + + it("applies project denyKeys to context", () => { + const doc = buildIssueExportDocument( + baseSource({ + scrubOptions: { denyKeys: ["path"] }, + }) + ); + expect(doc.latest_occurrence?.context).toMatchObject({ + path: "[redacted]", + }); + }); +}); + +describe("issueExportToMarkdown", () => { + it("renders a readable summary from the same document", () => { + const doc = buildIssueExportDocument(baseSource()); + const md = issueExportToMarkdown(doc); + expect(md).toContain("## TypeError: boom for [email]"); + expect(md).toContain("**Occurrences:** 42"); + expect(md).toContain("**Telemetry Tracker:** 1.17.3"); + expect(md).toContain("### Stack"); + expect(md).toContain("[View in Telemetry Tracker]"); + }); + + it("notes missing occurrences instead of failing", () => { + const doc = buildIssueExportDocument( + baseSource({ + latestOccurrence: null, + issue: { + ...baseSource().issue, + top_stack: null, + symbolicated_top_stack: null, + }, + }) + ); + const md = issueExportToMarkdown(doc); + expect(md).toContain("No occurrence available"); + }); +}); diff --git a/apps/api/src/lib/issue-export.ts b/apps/api/src/lib/issue-export.ts new file mode 100644 index 00000000..3d585bdc --- /dev/null +++ b/apps/api/src/lib/issue-export.ts @@ -0,0 +1,215 @@ +/** + * Stable developer/AI export contract for a single error issue. + * Schema: telemetry-tracker.issue.v1 + */ + +import { scrubPiiRecord, scrubPiiText, type PiiScrubOptions } from "./pii-scrub.js"; + +export const ISSUE_EXPORT_SCHEMA = "telemetry-tracker.issue.v1" as const; + +export type IssueExportV1 = { + schema: typeof ISSUE_EXPORT_SCHEMA; + telemetry_tracker_version: string; + exported_at: string; + project: { name: string }; + issue: { + id: string; + message: string; + fingerprint: string | null; + app: string; + environment: string | null; + platform: string | null; + release: string | null; + occurrences: number; + users_affected: number | null; + sessions_affected: number | null; + first_seen: string | null; + last_seen: string | null; + resolved_at: string | null; + top_stack: string | null; + symbolicated_top_stack: string | null; + /** Reserved for future diagnosis metadata (may be empty). */ + tags: Record; + }; + latest_occurrence: { + id: string; + created_at: string; + stack: string | null; + symbolicated_stack: string | null; + context: Record | null; + user_id: string | null; + session_id: string | null; + release: string | null; + sdk_version: string | null; + } | null; + dashboard_url: string | null; +}; + +export type IssueExportSource = { + telemetryTrackerVersion: string; + exportedAt?: Date; + projectName: string; + issue: { + id: string; + message: string; + fingerprint: string | null; + app: string; + environment: string | null; + platform: string | null; + release: string | null; + occurrences: number; + users_affected?: number | null; + sessions_affected?: number | null; + first_seen: Date | string | null; + last_seen: Date | string | null; + resolved_at: Date | string | null; + top_stack: string | null; + symbolicated_top_stack?: string | null; + tags?: Record; + }; + latestOccurrence: { + id: string; + created_at: Date | string; + stack: string | null; + symbolicated_stack?: string | null; + context?: unknown; + user_id?: string | null; + session_id?: string | null; + release?: string | null; + sdk_version?: string | null; + } | null; + dashboardUrl: string | null; + scrubOptions?: PiiScrubOptions; +}; + +function toIso(value: Date | string | null | undefined): string | null { + if (value == null) return null; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function asContextRecord(value: unknown): Record | null { + if (value == null) return null; + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function scrubNullableText(value: string | null | undefined): string | null { + if (value == null) return null; + return scrubPiiText(value); +} + +/** Build a versioned issue export document (PII-scrubbed). */ +export function buildIssueExportDocument(source: IssueExportSource): IssueExportV1 { + const scrub = source.scrubOptions; + const occ = source.latestOccurrence; + let context: Record | null = asContextRecord(occ?.context); + if (context) { + context = scrubPiiRecord(context, scrub) ?? null; + } + + return { + schema: ISSUE_EXPORT_SCHEMA, + telemetry_tracker_version: source.telemetryTrackerVersion, + exported_at: (source.exportedAt ?? new Date()).toISOString(), + project: { name: source.projectName }, + issue: { + id: source.issue.id, + message: scrubPiiText(source.issue.message), + fingerprint: source.issue.fingerprint, + app: source.issue.app, + environment: source.issue.environment, + platform: source.issue.platform, + release: source.issue.release, + occurrences: source.issue.occurrences, + users_affected: source.issue.users_affected ?? null, + sessions_affected: source.issue.sessions_affected ?? null, + first_seen: toIso(source.issue.first_seen), + last_seen: toIso(source.issue.last_seen), + resolved_at: toIso(source.issue.resolved_at), + top_stack: scrubNullableText(source.issue.top_stack), + symbolicated_top_stack: scrubNullableText( + source.issue.symbolicated_top_stack ?? null + ), + tags: source.issue.tags ?? {}, + }, + latest_occurrence: occ + ? { + id: occ.id, + created_at: toIso(occ.created_at) ?? new Date(0).toISOString(), + stack: scrubNullableText(occ.stack), + symbolicated_stack: scrubNullableText(occ.symbolicated_stack ?? null), + context, + user_id: occ.user_id ?? null, + session_id: occ.session_id ?? null, + release: occ.release ?? null, + sdk_version: occ.sdk_version ?? null, + } + : null, + dashboard_url: source.dashboardUrl, + }; +} + +/** Markdown view of the same export document (for Copy as Markdown). */ +export function issueExportToMarkdown(doc: IssueExportV1): string { + const lines: string[] = []; + lines.push(`## ${doc.issue.message}`); + lines.push(""); + const counts = [ + `**Occurrences:** ${doc.issue.occurrences}`, + doc.issue.users_affected != null ? `**Users:** ${doc.issue.users_affected}` : null, + doc.issue.sessions_affected != null + ? `**Sessions:** ${doc.issue.sessions_affected}` + : null, + ].filter(Boolean); + if (counts.length) lines.push(`- ${counts.join(" · ")}`); + + const scope = [ + doc.issue.app ? `**App:** ${doc.issue.app}` : null, + doc.issue.environment ? `**Env:** ${doc.issue.environment}` : null, + doc.issue.release ? `**Release:** ${doc.issue.release}` : null, + doc.issue.platform ? `**Platform:** ${doc.issue.platform}` : null, + ].filter(Boolean); + if (scope.length) lines.push(`- ${scope.join(" · ")}`); + + if (doc.issue.first_seen || doc.issue.last_seen) { + lines.push( + `- **First / last seen:** ${doc.issue.first_seen ?? "—"} / ${doc.issue.last_seen ?? "—"}` + ); + } + lines.push(`- **Telemetry Tracker:** ${doc.telemetry_tracker_version}`); + if (doc.project.name) { + lines.push(`- **Project:** ${doc.project.name}`); + } + + const stack = + doc.latest_occurrence?.symbolicated_stack || + doc.latest_occurrence?.stack || + doc.issue.symbolicated_top_stack || + doc.issue.top_stack; + + lines.push(""); + if (stack) { + lines.push("### Stack"); + lines.push(""); + lines.push("```"); + lines.push(stack); + lines.push("```"); + lines.push(""); + } else if (!doc.latest_occurrence) { + lines.push("_No occurrence available to export (e.g. retention)._"); + lines.push(""); + } + + if (doc.dashboard_url) { + lines.push(`[View in Telemetry Tracker](${doc.dashboard_url})`); + lines.push(""); + } + + return lines.join("\n").trimEnd() + "\n"; +} diff --git a/apps/api/src/routes/api.ts b/apps/api/src/routes/api.ts index 64b4040d..d6d3974c 100644 --- a/apps/api/src/routes/api.ts +++ b/apps/api/src/routes/api.ts @@ -132,6 +132,7 @@ import { resolveReadProjectId, resolveReadProjectIdWithSession, } from "../lib/read-project-request.js"; +import { loadIssueExportDocument } from "../lib/issue-export-load.js"; import { releasePrismaWhere } from "../lib/release-key.js"; import { EVENT_SORT_SQL, @@ -1170,6 +1171,41 @@ export async function apiRoutes( }); }); + app.get<{ Params: { id: string } }>("/errors/:id/export", async (request, reply) => { + const projectId = await resolveReadProjectId(request, reply); + if (projectId === null) return; + const { id } = request.params; + const query = request.query as { + app?: string; + environment?: string; + platform?: string; + release?: string; + range?: string; + from?: string; + to?: string; + metricsUntil?: string; + metricsSince?: string; + }; + const doc = await loadIssueExportDocument(prisma, projectId, id, { + app: queryApp(query.app), + environment: queryString(query.environment), + platform: queryString(query.platform), + release: queryString(query.release), + range: queryString(query.range), + from: queryString(query.from), + to: queryString(query.to), + metricsUntil: queryString(query.metricsUntil), + metricsSince: queryString(query.metricsSince), + }); + if (!doc) return reply.status(404).send({ error: "Not found" }); + return reply + .header( + "Content-Disposition", + `attachment; filename="issue-${id}.json"` + ) + .send(doc); + }); + app.get("/events/summary", async (request, reply) => { const projectId = await resolveReadProjectId(request, reply); if (projectId === null) return; diff --git a/apps/dashboard/app/api/telemetry/[...path]/route.ts b/apps/dashboard/app/api/telemetry/[...path]/route.ts index a1374b8d..47c54123 100644 --- a/apps/dashboard/app/api/telemetry/[...path]/route.ts +++ b/apps/dashboard/app/api/telemetry/[...path]/route.ts @@ -2,6 +2,8 @@ import { dashboardApiFetch } from "@/lib/dashboard-api"; const ALLOWED_ROOTS = new Set(["sessions", "events", "errors"]); const SAFE_SEGMENT_RE = /^[a-z]+$/; +const RESOURCE_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function isAllowedTelemetryPath(path: string[]): boolean { if (path.length === 1) { @@ -14,6 +16,13 @@ function isAllowedTelemetryPath(path: string[]): boolean { path[1] === "analytics" ); } + if (path.length === 3) { + return ( + path[0] === "errors" && + RESOURCE_ID_RE.test(path[1]!) && + path[2] === "export" + ); + } return false; } @@ -31,10 +40,14 @@ export async function GET( const upstream = await dashboardApiFetch(apiPath); const body = await upstream.text(); + const headers: Record = { + "Content-Type": upstream.headers.get("content-type") ?? "application/json", + }; + const disposition = upstream.headers.get("content-disposition"); + if (disposition) headers["Content-Disposition"] = disposition; + return new Response(body, { status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") ?? "application/json", - }, + headers, }); } diff --git a/apps/dashboard/app/dashboard/errors/ErrorExportActions.tsx b/apps/dashboard/app/dashboard/errors/ErrorExportActions.tsx new file mode 100644 index 00000000..da83cf67 --- /dev/null +++ b/apps/dashboard/app/dashboard/errors/ErrorExportActions.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useCallback, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { SettingsBtn } from "@/app/components/dashboard/settings/settings-ui"; +import { dashboardApiClientFetch } from "@/lib/dashboard-api-client"; +import { + issueExportToMarkdown, + type IssueExportV1, +} from "@/lib/issue-export"; + +export type ErrorExportScope = { + app?: string; + environment?: string; + platform?: string; + release?: string; + range?: string; + from?: string; + to?: string; + metricsUntil?: string; + metricsSince?: string; +}; + +function buildExportQuery(scope: ErrorExportScope): string { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(scope)) { + if (typeof value === "string" && value.trim()) qs.set(key, value.trim()); + } + const q = qs.toString(); + return q ? `?${q}` : ""; +} + +async function fetchIssueExport( + errorGroupId: string, + scope: ErrorExportScope +): Promise { + const res = await dashboardApiClientFetch( + `/api/errors/${encodeURIComponent(errorGroupId)}/export${buildExportQuery(scope)}` + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(text.slice(0, 200) || `Export failed (${res.status})`); + } + return (await res.json()) as IssueExportV1; +} + +async function copyText(text: string): Promise { + await navigator.clipboard.writeText(text); +} + +function downloadJson(doc: IssueExportV1, errorGroupId: string): void { + const blob = new Blob([`${JSON.stringify(doc, null, 2)}\n`], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `issue-${errorGroupId}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export function ErrorExportActions({ + errorGroupId, + scope, +}: { + errorGroupId: string; + scope: ErrorExportScope; +}) { + const [pending, startTransition] = useTransition(); + const [busy, setBusy] = useState<"json" | "download" | "md" | null>(null); + + const run = useCallback( + (kind: "json" | "download" | "md", work: (doc: IssueExportV1) => Promise | void) => { + startTransition(async () => { + setBusy(kind); + try { + const doc = await fetchIssueExport(errorGroupId, scope); + await work(doc); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Export failed"); + } finally { + setBusy(null); + } + }); + }, + [errorGroupId, scope] + ); + + return ( + <> + + run("json", async (doc) => { + await copyText(JSON.stringify(doc, null, 2)); + toast.success("Copied JSON"); + }) + } + > + {busy === "json" ? "…" : "Copy JSON"} + + + run("download", (doc) => { + downloadJson(doc, errorGroupId); + toast.success("Downloaded JSON"); + }) + } + > + {busy === "download" ? "…" : "Download JSON"} + + + run("md", async (doc) => { + await copyText(issueExportToMarkdown(doc)); + toast.success("Copied Markdown"); + }) + } + > + {busy === "md" ? "…" : "Copy Markdown"} + + + ); +} diff --git a/apps/dashboard/app/dashboard/errors/[id]/page.tsx b/apps/dashboard/app/dashboard/errors/[id]/page.tsx index 4804f196..086d20a1 100644 --- a/apps/dashboard/app/dashboard/errors/[id]/page.tsx +++ b/apps/dashboard/app/dashboard/errors/[id]/page.tsx @@ -13,6 +13,7 @@ import { StackTracePanel } from "@/app/components/dashboard/StackTracePanel"; import { StackTraceView } from "@/app/components/dashboard/StackTraceView"; import { MiniSparkline, type SparklinePoint } from "@/app/components/dashboard/MiniSparkline"; import { ErrorResolveButton } from "../ErrorResolveButton"; +import { ErrorExportActions } from "../ErrorExportActions"; import { dashboardApiFetch } from "@/lib/dashboard-api"; import { parseDashboardApiResourceId } from "@/lib/dashboard-api-url"; import { buildDashboardScopedListHref } from "@/lib/overview-scope-url"; @@ -295,7 +296,25 @@ export default async function ErrorDetailPage({ {resolved ? : null} } - actions={} + actions={ + <> + + + + } metrics={[ { label: "Occurrences", value: group.occurrences.toLocaleString() }, { diff --git a/apps/dashboard/lib/issue-export.test.ts b/apps/dashboard/lib/issue-export.test.ts new file mode 100644 index 00000000..2f834786 --- /dev/null +++ b/apps/dashboard/lib/issue-export.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + ISSUE_EXPORT_SCHEMA, + issueExportToMarkdown, + type IssueExportV1, +} from "./issue-export"; + +const sample: IssueExportV1 = { + schema: ISSUE_EXPORT_SCHEMA, + telemetry_tracker_version: "1.17.3", + exported_at: "2026-07-19T12:00:00.000Z", + project: { name: "production-api" }, + issue: { + id: "err-1", + message: "TypeError: boom", + fingerprint: "fp-1", + app: "web", + environment: "production", + platform: "javascript", + release: "1.2.3", + occurrences: 42, + users_affected: 12, + sessions_affected: 18, + first_seen: "2026-07-01T00:00:00.000Z", + last_seen: "2026-07-19T11:00:00.000Z", + resolved_at: null, + top_stack: null, + symbolicated_top_stack: null, + tags: {}, + }, + latest_occurrence: null, + dashboard_url: "https://app.example.com/dashboard/errors/err-1", +}; + +describe("issueExportToMarkdown", () => { + it("renders summary with missing occurrence note", () => { + const md = issueExportToMarkdown(sample); + expect(md).toContain("## TypeError: boom"); + expect(md).toContain("**Telemetry Tracker:** 1.17.3"); + expect(md).toContain("No occurrence available"); + expect(md).toContain("[View in Telemetry Tracker]"); + }); +}); diff --git a/apps/dashboard/lib/issue-export.ts b/apps/dashboard/lib/issue-export.ts new file mode 100644 index 00000000..20ccf832 --- /dev/null +++ b/apps/dashboard/lib/issue-export.ts @@ -0,0 +1,99 @@ +/** Client-side types + Markdown view for telemetry-tracker.issue.v1 exports. */ + +export const ISSUE_EXPORT_SCHEMA = "telemetry-tracker.issue.v1" as const; + +export type IssueExportV1 = { + schema: typeof ISSUE_EXPORT_SCHEMA; + telemetry_tracker_version: string; + exported_at: string; + project: { name: string }; + issue: { + id: string; + message: string; + fingerprint: string | null; + app: string; + environment: string | null; + platform: string | null; + release: string | null; + occurrences: number; + users_affected: number | null; + sessions_affected: number | null; + first_seen: string | null; + last_seen: string | null; + resolved_at: string | null; + top_stack: string | null; + symbolicated_top_stack: string | null; + tags: Record; + }; + latest_occurrence: { + id: string; + created_at: string; + stack: string | null; + symbolicated_stack: string | null; + context: Record | null; + user_id: string | null; + session_id: string | null; + release: string | null; + sdk_version: string | null; + } | null; + dashboard_url: string | null; +}; + +/** Markdown view of the same export document (for Copy as Markdown). */ +export function issueExportToMarkdown(doc: IssueExportV1): string { + const lines: string[] = []; + lines.push(`## ${doc.issue.message}`); + lines.push(""); + const counts = [ + `**Occurrences:** ${doc.issue.occurrences}`, + doc.issue.users_affected != null ? `**Users:** ${doc.issue.users_affected}` : null, + doc.issue.sessions_affected != null + ? `**Sessions:** ${doc.issue.sessions_affected}` + : null, + ].filter(Boolean); + if (counts.length) lines.push(`- ${counts.join(" · ")}`); + + const scope = [ + doc.issue.app ? `**App:** ${doc.issue.app}` : null, + doc.issue.environment ? `**Env:** ${doc.issue.environment}` : null, + doc.issue.release ? `**Release:** ${doc.issue.release}` : null, + doc.issue.platform ? `**Platform:** ${doc.issue.platform}` : null, + ].filter(Boolean); + if (scope.length) lines.push(`- ${scope.join(" · ")}`); + + if (doc.issue.first_seen || doc.issue.last_seen) { + lines.push( + `- **First / last seen:** ${doc.issue.first_seen ?? "—"} / ${doc.issue.last_seen ?? "—"}` + ); + } + lines.push(`- **Telemetry Tracker:** ${doc.telemetry_tracker_version}`); + if (doc.project.name) { + lines.push(`- **Project:** ${doc.project.name}`); + } + + const stack = + doc.latest_occurrence?.symbolicated_stack || + doc.latest_occurrence?.stack || + doc.issue.symbolicated_top_stack || + doc.issue.top_stack; + + lines.push(""); + if (stack) { + lines.push("### Stack"); + lines.push(""); + lines.push("```"); + lines.push(stack); + lines.push("```"); + lines.push(""); + } else if (!doc.latest_occurrence) { + lines.push("_No occurrence available to export (e.g. retention)._"); + lines.push(""); + } + + if (doc.dashboard_url) { + lines.push(`[View in Telemetry Tracker](${doc.dashboard_url})`); + lines.push(""); + } + + return lines.join("\n").trimEnd() + "\n"; +}