Skip to content

Commit a768c35

Browse files
KyleTryonclaudeCopilot
authored
feat: add Vercel AI SDK Sentry instrumentation and document OPENAI_AP… (#130)
* 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 <noreply@anthropic.com> * chore: format * chore: fix test * Update packages/api/src/entries/cloudflare.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: tests --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 4e49a8f commit a768c35

10 files changed

Lines changed: 139 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,86 @@ STAGING_CLOUDFLARE_PAGES_PROJECT_NAME # Staging Pages project name
132132
- **Security audit logging**: All auth events logged to `security_audit_log` table
133133
- **Rate limiting**: Cloudflare Workers rate limit API per plan tier
134134

135+
## AI Features Configuration
136+
137+
TuvixRSS includes optional AI-powered features using OpenAI and the Vercel AI SDK.
138+
139+
### Features
140+
141+
- **AI Category Suggestions**: Automatically suggests feed categories based on feed metadata and recent articles
142+
- **Model**: GPT-4o-mini (via `@ai-sdk/openai`)
143+
- **Location**: `packages/api/src/services/ai-category-suggester.ts`
144+
145+
### Feature Access Control
146+
147+
AI features are **triple-gated** for security and cost control:
148+
149+
1. **Global Setting**: `aiEnabled` flag in `global_settings` table (admin-controlled via admin dashboard)
150+
2. **User Plan**: Only Pro or Enterprise plan users have access
151+
3. **Environment**: `OPENAI_API_KEY` must be configured
152+
153+
Access check: `packages/api/src/services/limits.ts:checkAiFeatureAccess()`
154+
155+
### Configuration
156+
157+
**Local Development (Docker/Node.js):**
158+
159+
```env
160+
# Add to .env
161+
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
162+
```
163+
164+
**Cloudflare Workers (Production/Staging):**
165+
166+
```bash
167+
# Use wrangler CLI to set secret
168+
cd packages/api
169+
npx wrangler secret put OPENAI_API_KEY
170+
# Enter: sk-proj-xxxxxxxxxxxxx
171+
```
172+
173+
**GitHub Actions (CI/CD):**
174+
Add `OPENAI_API_KEY` to repository secrets for production deployments.
175+
176+
### Sentry Instrumentation
177+
178+
AI calls are automatically tracked by Sentry via the `vercelAIIntegration`:
179+
180+
- **Token usage**: Tracked automatically by AI SDK telemetry
181+
- **Latency**: Per-call duration metrics
182+
- **Model info**: Model name and version
183+
- **Errors**: AI SDK errors and failures
184+
- **Input/Output**: Captured when `experimental_telemetry.recordInputs/recordOutputs` is enabled
185+
186+
**Configuration:**
187+
188+
- Node.js: `packages/api/src/entries/node.ts` (Sentry.init with vercelAIIntegration)
189+
- Cloudflare: `packages/api/src/entries/cloudflare.ts` (withSentry config)
190+
- AI calls: Include `experimental_telemetry` with `functionId` for better tracking
191+
192+
**Example:**
193+
194+
```typescript
195+
const result = await generateObject({
196+
model: openai("gpt-4o-mini"),
197+
// ... schema and prompts
198+
experimental_telemetry: {
199+
isEnabled: true,
200+
functionId: "ai.suggestCategories",
201+
recordInputs: true,
202+
recordOutputs: true,
203+
},
204+
});
205+
```
206+
207+
### Best Practices
208+
209+
1. **Always check access**: Use `checkAiFeatureAccess()` before calling AI services
210+
2. **Graceful degradation**: Return `undefined` if AI is unavailable (don't error)
211+
3. **Add telemetry**: Include `experimental_telemetry` in all AI SDK calls
212+
4. **Function IDs**: Use descriptive `functionId` for easier tracking in Sentry
213+
5. **Cost awareness**: AI features are gated to Pro/Enterprise to manage costs
214+
135215
## Observability with Sentry
136216

137217
TuvixRSS uses Sentry for comprehensive observability: error tracking, performance monitoring, and custom metrics.

docs/deployment.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,11 @@ ADMIN_PASSWORD=<secure-password>
295295
# RESEND_API_KEY=re_xxxxxxxxx
296296
# EMAIL_FROM=noreply@yourdomain.com
297297
298+
# Optional: AI Features (requires Pro or Enterprise plan)
299+
# OpenAI API key for AI-powered category suggestions
300+
# Get your API key from: https://platform.openai.com/api-keys
301+
# OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
302+
298303
# Optional: Customize fetch behavior
299304
FETCH_INTERVAL_MINUTES=60 # How often to fetch RSS feeds
300305
```
@@ -662,6 +667,12 @@ npx wrangler secret put RESEND_API_KEY
662667
npx wrangler secret put EMAIL_FROM
663668
npx wrangler secret put BASE_URL
664669

670+
# AI Features (requires Pro or Enterprise plan)
671+
# OpenAI API key for AI-powered category suggestions
672+
# Get your API key from: https://platform.openai.com/api-keys
673+
npx wrangler secret put OPENAI_API_KEY
674+
# Enter: sk-proj-xxxxxxxxxxxxx
675+
665676
# Cross-subdomain cookies (if frontend/API on different subdomains)
666677
npx wrangler secret put COOKIE_DOMAIN
667678
# Enter: example.com (root domain, not subdomain like api.example.com)

env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ ADMIN_PASSWORD=change-me-in-production
7575
# is more deterministic and avoids any timing concerns.
7676
# ALLOW_FIRST_USER_ADMIN=true
7777

78+
# AI Features (optional - requires Pro or Enterprise plan)
79+
# OpenAI API key for AI-powered category suggestions
80+
# Get your API key from: https://platform.openai.com/api-keys
81+
# Leave unset to disable AI features
82+
# OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
83+
7884
# Sentry Configuration (optional)
7985
# Backend Sentry DSN (for Express/Cloudflare Workers)
8086
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx

packages/api/src/config/sentry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export function getSentryConfig(env: Env): Record<string, unknown> | null {
7676
// Debug mode (verbose logging - useful for development)
7777
debug: environment === "development",
7878

79+
// Vercel AI SDK integration for automatic AI span tracking
80+
// Captures token usage, model info, latency, and errors from AI SDK calls
81+
// Note: Integration setup is handled differently for Cloudflare Workers vs Node.js
82+
// For Cloudflare, integrations are configured in the entry point via withSentry
83+
enableAIIntegration: true,
84+
7985
/**
8086
* beforeSendMetric callback
8187
*

packages/api/src/entries/cloudflare.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ export default Sentry.withSentry((env: Env) => {
116116
config.release = versionId;
117117
}
118118

119+
// Add Vercel AI SDK integration for automatic AI span tracking
120+
// Captures token usage, model info, latency, and errors from AI SDK calls
121+
// Note: Input/output recording is controlled via experimental_telemetry in AI SDK calls
122+
// Type assertion needed since getSentryConfig returns Record<string, unknown>
123+
const existingIntegrations = Array.isArray(config.integrations)
124+
? (config.integrations as unknown[])
125+
: [];
126+
config.integrations = [...existingIntegrations, Sentry.vercelAIIntegration()];
127+
119128
// Log Sentry initialization in development
120129
const environment = (env.SENTRY_ENVIRONMENT ||
121130
env.NODE_ENV ||
@@ -125,6 +134,7 @@ export default Sentry.withSentry((env: Env) => {
125134
environment,
126135
release: config.release,
127136
hasDsn: !!config.dsn,
137+
aiTracking: true,
128138
});
129139
}
130140

packages/api/src/entries/node.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,15 @@ if (env.SENTRY_DSN) {
6262
ignoreIncomingRequestBody: (url) => url.includes("/trpc"),
6363
}),
6464
Sentry.nativeNodeFetchIntegration(),
65+
// Vercel AI SDK integration for automatic AI span tracking
66+
// Captures token usage, model info, latency, and errors from AI SDK calls
67+
Sentry.vercelAIIntegration({
68+
recordInputs: true, // Safe: only used for pro/enterprise users with opt-in
69+
recordOutputs: true, // Captures structured category suggestions
70+
}),
6571
],
6672
});
67-
console.log("✅ Sentry initialized (with metrics enabled)");
73+
console.log("✅ Sentry initialized (with metrics and AI tracking enabled)");
6874
}
6975
}
7076

packages/api/src/services/ai-category-suggester.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ INSTRUCTIONS:
117117
prompt:
118118
"Based on the provided context, suggest relevant categories for this RSS feed.",
119119
system: systemPrompt,
120+
// Enable Sentry AI SDK telemetry for automatic span tracking
121+
// Captures token usage, model info, latency, and errors
122+
experimental_telemetry: {
123+
isEnabled: true,
124+
functionId: "ai.suggestCategories",
125+
recordInputs: true, // Safe: only captures feed metadata, not user PII
126+
recordOutputs: true, // Captures structured category suggestions
127+
},
120128
});
121129

122130
// Filter by confidence threshold (85%)

packages/app/src/__tests__/routes/app-admin-route-offline.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ const routeModule = await import("../../routes/app/admin/route");
4343
describe("Admin Route - Offline Navigation", () => {
4444
beforeEach(() => {
4545
vi.clearAllMocks();
46-
localStorage.clear();
46+
if (typeof localStorage.clear === "function") {
47+
localStorage.clear();
48+
}
4749
});
4850

4951
describe("network error handling", () => {

packages/app/src/__tests__/routes/app-route-offline.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ const routeModule = await import("../../routes/app/route");
107107
describe("App Route - Offline Navigation", () => {
108108
beforeEach(() => {
109109
vi.clearAllMocks();
110-
localStorage.clear();
110+
if (typeof localStorage.clear === "function") {
111+
localStorage.clear();
112+
}
111113

112114
// Reset mocks
113115
mockCheckVerificationStatus.mockResolvedValue({

packages/app/src/components/settings/__tests__/pwa-install-card.integration.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ describe("PWAInstallCard Integration Tests", () => {
156156
});
157157

158158
// Verify success toast was shown
159-
expect(toast.success).toHaveBeenCalledWith("App installed successfully!");
159+
await waitFor(() => {
160+
expect(toast.success).toHaveBeenCalledWith(
161+
"App installed successfully!"
162+
);
163+
});
160164

161165
// Verify installed state UI
162166
expect(

0 commit comments

Comments
 (0)