From b2cfaa3e58bf3bf8624dbaf9056e88436d684253 Mon Sep 17 00:00:00 2001 From: Benjamin Coe Date: Sat, 27 Jun 2026 11:23:22 -0400 Subject: [PATCH] experiment: experiment with adding logs using skill --- sentry.edge.config.ts | 1 + sentry.server.config.ts | 1 + src/app/api/revalidate/route.ts | 14 +++++++ src/instrumentation-client.ts | 1 + src/server/actions/changelog.ts | 68 +++++++++++++++++++++++++++++++-- src/server/authOptions.ts | 10 +++++ src/types/next-auth.d.ts | 11 ++++++ 7 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/types/next-auth.d.ts diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 3ef3478..483032d 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 1, + enableLogs: true, traceLifecycle: 'stream', environment: process.env.NODE_ENV, spotlight: process.env.NODE_ENV === 'development', diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 47bdab8..0221cd2 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 1, + enableLogs: true, traceLifecycle: 'stream', environment: process.env.NODE_ENV, integrations: [Sentry.nodeRuntimeMetricsIntegration()], diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index becf37f..7143277 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/nextjs"; import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; @@ -14,6 +15,13 @@ export async function POST(request: Request) { const provided = request.headers.get("x-revalidate-secret"); if (!secret || provided !== secret) { + // Security-relevant: the only legitimate caller is the out-of-band sync + // job. Distinguish a misconfigured server from a bad/forged secret without + // ever logging the provided value. + Sentry.logger.warn("Revalidation request rejected", { + "api.endpoint": "revalidate", + "api.reject_reason": secret ? "invalid_secret" : "secret_not_configured", + }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -21,5 +29,11 @@ export async function POST(request: Request) { revalidateTag(tag, "max"); } + // Operational: confirms the sync job's post-commit cache bust reached the app. + Sentry.logger.info("Site cache revalidated", { + "api.endpoint": "revalidate", + "revalidate.tags": TAGS.join(","), + }); + return NextResponse.json({ revalidated: true, tags: TAGS }); } diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 5d90c4f..cdb7ad0 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -5,6 +5,7 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, tracesSampleRate: 1, + enableLogs: true, replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.1, integrations: [ diff --git a/src/server/actions/changelog.ts b/src/server/actions/changelog.ts index 16c2296..6a50054 100644 --- a/src/server/actions/changelog.ts +++ b/src/server/actions/changelog.ts @@ -1,5 +1,6 @@ "use server"; +import * as Sentry from "@sentry/nextjs"; import { eq, inArray } from "drizzle-orm"; import { revalidatePath, revalidateTag } from "next/cache"; import { redirect } from "next/navigation"; @@ -71,6 +72,7 @@ export async function unpublishChangelog( if (!session) { return unauthorizedPayload; } + Sentry.setUser({ id: session.user?.id }); const id = formData.get("id") as string; try { @@ -79,10 +81,20 @@ export async function unpublishChangelog( .set({ published: false, adminManaged: true }) .where(eq(Changelog.id, id)); } catch (error) { - console.error("DELETE ACTION ERROR:", error); + Sentry.logger.error("Changelog unpublish failed", { + "changelog.id": id, + "changelog.action": "unpublish", + "error.message": error instanceof Error ? error.message : String(error), + }); return { message: "Unable to unpublish changelog", success: false }; } + // Audit: a previously public entry was hidden from the changelog. + Sentry.logger.info("Changelog unpublished", { + "changelog.id": id, + "changelog.action": "unpublish", + }); + revalidateTag("changelogs", "max"); revalidateTag("changelog-detail", "max"); revalidatePath("/changelog/_admin"); @@ -98,6 +110,7 @@ export async function publishChangelog( if (!session) { return unauthorizedPayload; } + Sentry.setUser({ id: session.user?.id }); const id = formData.get("id") as string; try { @@ -120,10 +133,20 @@ export async function publishChangelog( }) .where(eq(Changelog.id, id)); } catch (error) { - console.error("DELETE ACTION ERROR:", error); + Sentry.logger.error("Changelog publish failed", { + "changelog.id": id, + "changelog.action": "publish", + "error.message": error instanceof Error ? error.message : String(error), + }); return { message: "Unable to publish changelog", success: false }; } + // Audit: an entry became publicly visible on the changelog. + Sentry.logger.info("Changelog published", { + "changelog.id": id, + "changelog.action": "publish", + }); + revalidateTag("changelogs", "max"); revalidateTag("changelog-detail", "max"); revalidatePath("/changelog/_admin"); @@ -138,6 +161,7 @@ export async function createChangelog( if (!session) { return unauthorizedPayload; } + Sentry.setUser({ id: session.user?.id }); const categoryNames = uniqueCategoryNames(getFormCategoryNames(formData)); if (categoryNames.length > 0) { @@ -184,6 +208,16 @@ export async function createChangelog( await syncChangelogCategories(changelog.id, categoryNames); + // Audit: a new entry was authored through the admin UI (always a draft; + // platform/category counts show how the entry was targeted at creation). + Sentry.logger.info("Changelog created", { + "changelog.id": changelog.id, + "changelog.slug": formData.get("slug") as string, + "changelog.action": "create", + "changelog.platform_count": parsePlatforms(formData).length, + "changelog.category_count": categoryNames.length, + }); + return redirect("/changelog/_admin"); } @@ -195,6 +229,7 @@ export async function editChangelog( if (!session) { return unauthorizedPayload; } + Sentry.setUser({ id: session.user?.id }); const id = formData.get("id") as string; const categoryNames = uniqueCategoryNames(getFormCategoryNames(formData)); @@ -222,10 +257,24 @@ export async function editChangelog( await syncChangelogCategories(id, categoryNames); } catch (error: any) { - console.error("EDIT ACTION ERROR:", error); + Sentry.logger.error("Changelog edit failed", { + "changelog.id": id, + "changelog.action": "edit", + "error.message": error instanceof Error ? error.message : String(error), + }); return { message: (error as Error).message, success: false }; } + // Audit: an existing entry's content or targeting was changed via the admin + // UI, which also marks it admin-managed so the file sync no longer touches it. + Sentry.logger.info("Changelog updated", { + "changelog.id": id, + "changelog.slug": formData.get("slug") as string, + "changelog.action": "edit", + "changelog.platform_count": parsePlatforms(formData).length, + "changelog.category_count": categoryNames.length, + }); + revalidateTag("changelogs", "max"); revalidateTag("changelog-detail", "max"); return redirect("/changelog/_admin"); @@ -239,6 +288,7 @@ export async function deleteChangelog( if (!session) { return unauthorizedPayload; } + Sentry.setUser({ id: session.user?.id }); const id = formData.get("id") as string; try { // Soft delete: mark deleted + admin-managed instead of removing the row, @@ -254,10 +304,20 @@ export async function deleteChangelog( }) .where(eq(Changelog.id, id)); } catch (error) { - console.error("DELETE ACTION ERROR:", error); + Sentry.logger.error("Changelog delete failed", { + "changelog.id": id, + "changelog.action": "delete", + "error.message": error instanceof Error ? error.message : String(error), + }); return { message: "Unable to delete changelog", success: false }; } + // Audit: an entry was soft-deleted (hidden, but the row is retained). + Sentry.logger.info("Changelog deleted", { + "changelog.id": id, + "changelog.action": "delete", + }); + revalidateTag("changelogs", "max"); revalidateTag("changelog-detail", "max"); revalidatePath("/changelog/_admin"); diff --git a/src/server/authOptions.ts b/src/server/authOptions.ts index 67dfa5b..672535c 100644 --- a/src/server/authOptions.ts +++ b/src/server/authOptions.ts @@ -41,4 +41,14 @@ export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, + callbacks: { + // Surface the opaque user id (the JWT subject) on the session so server + // code can attach it to Sentry via setUser without logging the email (PII). + session({ session, token }) { + if (session.user && token.sub) { + session.user.id = token.sub; + } + return session; + }, + }, }; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..0fcf4ba --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,11 @@ +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session { + user?: { + // Opaque user id (JWT subject), populated in the session callback so it + // can be passed to Sentry.setUser without exposing the user's email. + id?: string; + } & DefaultSession["user"]; + } +}