From 6bf27f5d346a79521a26e4274a34ec54692bb5ea Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sun, 11 Jan 2026 21:02:22 -0500 Subject: [PATCH 1/5] feat: add Vercel AI SDK Sentry instrumentation and document OPENAI_API_KEY - Add vercelAIIntegration to Sentry for both Node.js and Cloudflare Workers - Enable experimental_telemetry in AI SDK calls for automatic span tracking - Track token usage, model info, latency, and errors for AI operations - Document OPENAI_API_KEY in env.example, deployment.md, and CLAUDE.md - Clarify secret configuration: use wrangler CLI for Cloudflare Workers - Add AI Features Configuration section to project guidelines Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 76 +++++++++++++++++++ docs/deployment.md | 11 +++ env.example | 6 ++ packages/api/src/config/sentry.ts | 6 ++ packages/api/src/entries/cloudflare.ts | 6 ++ packages/api/src/entries/node.ts | 8 +- .../api/src/services/ai-category-suggester.ts | 8 ++ 7 files changed, 120 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb293c0d..6d7fa5fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,82 @@ STAGING_CLOUDFLARE_PAGES_PROJECT_NAME # Staging Pages project name - **Security audit logging**: All auth events logged to `security_audit_log` table - **Rate limiting**: Cloudflare Workers rate limit API per plan tier +## AI Features Configuration + +TuvixRSS includes optional AI-powered features using OpenAI and the Vercel AI SDK. + +### Features + +- **AI Category Suggestions**: Automatically suggests feed categories based on feed metadata and recent articles +- **Model**: GPT-4o-mini (via `@ai-sdk/openai`) +- **Location**: `packages/api/src/services/ai-category-suggester.ts` + +### Feature Access Control + +AI features are **triple-gated** for security and cost control: + +1. **Global Setting**: `aiEnabled` flag in `global_settings` table (admin-controlled via admin dashboard) +2. **User Plan**: Only Pro or Enterprise plan users have access +3. **Environment**: `OPENAI_API_KEY` must be configured + +Access check: `packages/api/src/services/limits.ts:checkAiFeatureAccess()` + +### Configuration + +**Local Development (Docker/Node.js):** +```env +# Add to .env +OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx +``` + +**Cloudflare Workers (Production/Staging):** +```bash +# Use wrangler CLI to set secret +cd packages/api +npx wrangler secret put OPENAI_API_KEY +# Enter: sk-proj-xxxxxxxxxxxxx +``` + +**GitHub Actions (CI/CD):** +Add `OPENAI_API_KEY` to repository secrets for production deployments. + +### Sentry Instrumentation + +AI calls are automatically tracked by Sentry via the `vercelAIIntegration`: + +- **Token usage**: Tracked automatically by AI SDK telemetry +- **Latency**: Per-call duration metrics +- **Model info**: Model name and version +- **Errors**: AI SDK errors and failures +- **Input/Output**: Captured when `experimental_telemetry.recordInputs/recordOutputs` is enabled + +**Configuration:** +- Node.js: `packages/api/src/entries/node.ts` (Sentry.init with vercelAIIntegration) +- Cloudflare: `packages/api/src/entries/cloudflare.ts` (withSentry config) +- AI calls: Include `experimental_telemetry` with `functionId` for better tracking + +**Example:** +```typescript +const result = await generateObject({ + model: openai("gpt-4o-mini"), + // ... schema and prompts + experimental_telemetry: { + isEnabled: true, + functionId: "ai.suggestCategories", + recordInputs: true, + recordOutputs: true, + }, +}); +``` + +### Best Practices + +1. **Always check access**: Use `checkAiFeatureAccess()` before calling AI services +2. **Graceful degradation**: Return `undefined` if AI is unavailable (don't error) +3. **Add telemetry**: Include `experimental_telemetry` in all AI SDK calls +4. **Function IDs**: Use descriptive `functionId` for easier tracking in Sentry +5. **Cost awareness**: AI features are gated to Pro/Enterprise to manage costs + ## Observability with Sentry TuvixRSS uses Sentry for comprehensive observability: error tracking, performance monitoring, and custom metrics. diff --git a/docs/deployment.md b/docs/deployment.md index 1babd7b9..09ad18c7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -295,6 +295,11 @@ ADMIN_PASSWORD= # RESEND_API_KEY=re_xxxxxxxxx # EMAIL_FROM=noreply@yourdomain.com +# Optional: AI Features (requires Pro or Enterprise plan) +# OpenAI API key for AI-powered category suggestions +# Get your API key from: https://platform.openai.com/api-keys +# OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx + # Optional: Customize fetch behavior FETCH_INTERVAL_MINUTES=60 # How often to fetch RSS feeds ``` @@ -662,6 +667,12 @@ npx wrangler secret put RESEND_API_KEY npx wrangler secret put EMAIL_FROM npx wrangler secret put BASE_URL +# AI Features (requires Pro or Enterprise plan) +# OpenAI API key for AI-powered category suggestions +# Get your API key from: https://platform.openai.com/api-keys +npx wrangler secret put OPENAI_API_KEY +# Enter: sk-proj-xxxxxxxxxxxxx + # Cross-subdomain cookies (if frontend/API on different subdomains) npx wrangler secret put COOKIE_DOMAIN # Enter: example.com (root domain, not subdomain like api.example.com) diff --git a/env.example b/env.example index 156f0cfb..ade79b36 100644 --- a/env.example +++ b/env.example @@ -75,6 +75,12 @@ ADMIN_PASSWORD=change-me-in-production # is more deterministic and avoids any timing concerns. # ALLOW_FIRST_USER_ADMIN=true +# AI Features (optional - requires Pro or Enterprise plan) +# OpenAI API key for AI-powered category suggestions +# Get your API key from: https://platform.openai.com/api-keys +# Leave unset to disable AI features +# OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx + # Sentry Configuration (optional) # Backend Sentry DSN (for Express/Cloudflare Workers) # SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx diff --git a/packages/api/src/config/sentry.ts b/packages/api/src/config/sentry.ts index cc80871d..7cc9720a 100644 --- a/packages/api/src/config/sentry.ts +++ b/packages/api/src/config/sentry.ts @@ -76,6 +76,12 @@ export function getSentryConfig(env: Env): Record | null { // Debug mode (verbose logging - useful for development) debug: environment === "development", + // Vercel AI SDK integration for automatic AI span tracking + // Captures token usage, model info, latency, and errors from AI SDK calls + // Note: Integration setup is handled differently for Cloudflare Workers vs Node.js + // For Cloudflare, integrations are configured in the entry point via withSentry + enableAIIntegration: true, + /** * beforeSendMetric callback * diff --git a/packages/api/src/entries/cloudflare.ts b/packages/api/src/entries/cloudflare.ts index 12038e51..a0a82855 100644 --- a/packages/api/src/entries/cloudflare.ts +++ b/packages/api/src/entries/cloudflare.ts @@ -116,6 +116,11 @@ export default Sentry.withSentry((env: Env) => { config.release = versionId; } + // Add Vercel AI SDK integration for automatic AI span tracking + // Captures token usage, model info, latency, and errors from AI SDK calls + // Note: Input/output recording is controlled via experimental_telemetry in AI SDK calls + config.integrations = [Sentry.vercelAIIntegration()]; + // Log Sentry initialization in development const environment = (env.SENTRY_ENVIRONMENT || env.NODE_ENV || @@ -125,6 +130,7 @@ export default Sentry.withSentry((env: Env) => { environment, release: config.release, hasDsn: !!config.dsn, + aiTracking: true, }); } diff --git a/packages/api/src/entries/node.ts b/packages/api/src/entries/node.ts index f4d0dabb..ab672bf0 100644 --- a/packages/api/src/entries/node.ts +++ b/packages/api/src/entries/node.ts @@ -62,9 +62,15 @@ if (env.SENTRY_DSN) { ignoreIncomingRequestBody: (url) => url.includes("/trpc"), }), Sentry.nativeNodeFetchIntegration(), + // Vercel AI SDK integration for automatic AI span tracking + // Captures token usage, model info, latency, and errors from AI SDK calls + Sentry.vercelAIIntegration({ + recordInputs: true, // Safe: only used for pro/enterprise users with opt-in + recordOutputs: true, // Captures structured category suggestions + }), ], }); - console.log("✅ Sentry initialized (with metrics enabled)"); + console.log("✅ Sentry initialized (with metrics and AI tracking enabled)"); } } diff --git a/packages/api/src/services/ai-category-suggester.ts b/packages/api/src/services/ai-category-suggester.ts index b4de5128..db9e203a 100644 --- a/packages/api/src/services/ai-category-suggester.ts +++ b/packages/api/src/services/ai-category-suggester.ts @@ -117,6 +117,14 @@ INSTRUCTIONS: prompt: "Based on the provided context, suggest relevant categories for this RSS feed.", system: systemPrompt, + // Enable Sentry AI SDK telemetry for automatic span tracking + // Captures token usage, model info, latency, and errors + experimental_telemetry: { + isEnabled: true, + functionId: "ai.suggestCategories", + recordInputs: true, // Safe: only captures feed metadata, not user PII + recordOutputs: true, // Captures structured category suggestions + }, }); // Filter by confidence threshold (85%) From b9de81d2e54c3cd2b5918231fc1354ab09b731e0 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sun, 11 Jan 2026 23:07:08 -0500 Subject: [PATCH 2/5] chore: format --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6d7fa5fc..a8ed64ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,12 +155,14 @@ Access check: `packages/api/src/services/limits.ts:checkAiFeatureAccess()` ### Configuration **Local Development (Docker/Node.js):** + ```env # Add to .env OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx ``` **Cloudflare Workers (Production/Staging):** + ```bash # Use wrangler CLI to set secret cd packages/api @@ -182,11 +184,13 @@ AI calls are automatically tracked by Sentry via the `vercelAIIntegration`: - **Input/Output**: Captured when `experimental_telemetry.recordInputs/recordOutputs` is enabled **Configuration:** + - Node.js: `packages/api/src/entries/node.ts` (Sentry.init with vercelAIIntegration) - Cloudflare: `packages/api/src/entries/cloudflare.ts` (withSentry config) - AI calls: Include `experimental_telemetry` with `functionId` for better tracking **Example:** + ```typescript const result = await generateObject({ model: openai("gpt-4o-mini"), From e883a3e189b21c0dc74d933400cc9dd4348d5677 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sun, 11 Jan 2026 23:36:03 -0500 Subject: [PATCH 3/5] chore: fix test --- .../__tests__/pwa-install-card.integration.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings/__tests__/pwa-install-card.integration.test.tsx b/packages/app/src/components/settings/__tests__/pwa-install-card.integration.test.tsx index 7e109016..0bf0a635 100644 --- a/packages/app/src/components/settings/__tests__/pwa-install-card.integration.test.tsx +++ b/packages/app/src/components/settings/__tests__/pwa-install-card.integration.test.tsx @@ -156,7 +156,11 @@ describe("PWAInstallCard Integration Tests", () => { }); // Verify success toast was shown - expect(toast.success).toHaveBeenCalledWith("App installed successfully!"); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + "App installed successfully!" + ); + }); // Verify installed state UI expect( From c3b3e39669b1fb40e997bed266351957a04da54a Mon Sep 17 00:00:00 2001 From: "Kyle a.k.a. TechSquidTV" Date: Mon, 12 Jan 2026 00:03:51 -0500 Subject: [PATCH 4/5] Update packages/api/src/entries/cloudflare.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/api/src/entries/cloudflare.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/entries/cloudflare.ts b/packages/api/src/entries/cloudflare.ts index a0a82855..90634462 100644 --- a/packages/api/src/entries/cloudflare.ts +++ b/packages/api/src/entries/cloudflare.ts @@ -119,7 +119,10 @@ export default Sentry.withSentry((env: Env) => { // Add Vercel AI SDK integration for automatic AI span tracking // Captures token usage, model info, latency, and errors from AI SDK calls // Note: Input/output recording is controlled via experimental_telemetry in AI SDK calls - config.integrations = [Sentry.vercelAIIntegration()]; + config.integrations = [ + ...(config.integrations || []), + Sentry.vercelAIIntegration(), + ]; // Log Sentry initialization in development const environment = (env.SENTRY_ENVIRONMENT || From 6044a90a368d426d8b647d767ed34dc6c7573971 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Mon, 12 Jan 2026 00:32:54 -0500 Subject: [PATCH 5/5] fix: tests --- packages/api/src/entries/cloudflare.ts | 9 +++++---- .../__tests__/routes/app-admin-route-offline.test.tsx | 4 +++- .../app/src/__tests__/routes/app-route-offline.test.tsx | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/api/src/entries/cloudflare.ts b/packages/api/src/entries/cloudflare.ts index 90634462..5646ef09 100644 --- a/packages/api/src/entries/cloudflare.ts +++ b/packages/api/src/entries/cloudflare.ts @@ -119,10 +119,11 @@ export default Sentry.withSentry((env: Env) => { // Add Vercel AI SDK integration for automatic AI span tracking // Captures token usage, model info, latency, and errors from AI SDK calls // Note: Input/output recording is controlled via experimental_telemetry in AI SDK calls - config.integrations = [ - ...(config.integrations || []), - Sentry.vercelAIIntegration(), - ]; + // Type assertion needed since getSentryConfig returns Record + const existingIntegrations = Array.isArray(config.integrations) + ? (config.integrations as unknown[]) + : []; + config.integrations = [...existingIntegrations, Sentry.vercelAIIntegration()]; // Log Sentry initialization in development const environment = (env.SENTRY_ENVIRONMENT || diff --git a/packages/app/src/__tests__/routes/app-admin-route-offline.test.tsx b/packages/app/src/__tests__/routes/app-admin-route-offline.test.tsx index e200e173..26feed73 100644 --- a/packages/app/src/__tests__/routes/app-admin-route-offline.test.tsx +++ b/packages/app/src/__tests__/routes/app-admin-route-offline.test.tsx @@ -43,7 +43,9 @@ const routeModule = await import("../../routes/app/admin/route"); describe("Admin Route - Offline Navigation", () => { beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); + if (typeof localStorage.clear === "function") { + localStorage.clear(); + } }); describe("network error handling", () => { diff --git a/packages/app/src/__tests__/routes/app-route-offline.test.tsx b/packages/app/src/__tests__/routes/app-route-offline.test.tsx index a456648a..a0e55b5c 100644 --- a/packages/app/src/__tests__/routes/app-route-offline.test.tsx +++ b/packages/app/src/__tests__/routes/app-route-offline.test.tsx @@ -107,7 +107,9 @@ const routeModule = await import("../../routes/app/route"); describe("App Route - Offline Navigation", () => { beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); + if (typeof localStorage.clear === "function") { + localStorage.clear(); + } // Reset mocks mockCheckVerificationStatus.mockResolvedValue({