Skip to content

Observability/signup#74

Merged
KyleTryon merged 24 commits intodevfrom
observability/signup
Dec 4, 2025
Merged

Observability/signup#74
KyleTryon merged 24 commits intodevfrom
observability/signup

Conversation

@KyleTryon
Copy link
Contributor

This pull request introduces enhanced monitoring, security auditing, and reliability improvements to authentication and subscription-related API endpoints. The main changes include adding Sentry spans and breadcrumbs for observability, logging security events for authentication actions, and implementing retry logic for transient feed fetch failures.

Observability and Monitoring Enhancements:

  • Added Sentry spans and breadcrumbs to the authentication (authRouter) and articles (articlesRouter) endpoints to track login, password change, password reset, and batch article operations. This includes setting span attributes, capturing exceptions, and emitting metrics for success and failure cases. [1] [2] [3] [4] [5] [6]

Security Auditing Improvements:

  • Integrated security event logging for successful and failed login, password change, and password reset actions, capturing details like user ID, IP address, user agent, and error metadata. Audit logging failures are handled gracefully and reported to Sentry. [1] [2] [3] [4]

Reliability Improvements for Feed Fetching:

  • Implemented retry logic with exponential backoff for transient HTTP errors (e.g., 502, 503, 504, 429) when fetching feeds in the subscriptionsRouter. Retries are tracked with Sentry breadcrumbs, and final failures are reported with detailed error context. [1] [2] [3]

Utility Function Addition:

  • Added a new extractHeaders utility in security.ts to normalize headers from requests, supporting both Headers objects and plain objects. This is used throughout the routers for consistent header processing.

Code Quality and Consistency:

  • Refactored user ID extraction and error handling for authentication flows to improve clarity and maintainability. Ensured consistent use of extracted header information and robust error reporting. [1] [2]

Let me know if you want to dive deeper into any specific part of these changes!

KyleTryon and others added 7 commits December 3, 2025 16:13
Add extractHeaders() utility function to normalize request header
extraction across authentication flows. Handles both Headers object
and plain object formats, reducing code duplication by ~100 lines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fix silent email failures by adding comprehensive breadcrumbs and
proper error checking throughout email sending flows.

- Add breadcrumbs at email service level (attempt, API call, result)
- Change Better Auth callbacks from .catch() to .then() to check
  emailResult.success (email service returns errors, doesn't throw)
- Add breadcrumbs for verification, welcome, and password reset emails
- Add Sentry capture for settings check failures
- Ensures all email failures are tracked for debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add complete tracking and security logging throughout authentication
endpoints to prevent silent failures and enable monitoring.

Login flow enhancements:
- Wrap in Sentry span for performance tracking
- Add breadcrumbs for login attempts and results
- Add security audit logging for successful and failed logins
- Add metrics for monitoring login success rates and duration
- Use extractHeaders() utility for consistency

Password operation security:
- Add security audit logging to password change endpoint
- Query verification token before reset to capture userId
- Add security audit logging to password reset completion
- Non-blocking audit logging to prevent operation failures

All audit logging wrapped in try/catch to ensure authentication
operations succeed even if logging fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Wrap verification and welcome email operations in Sentry spans
to track success/failure even though emails are sent in a
fire-and-forget pattern.

- Add span tracking for verification email with status codes
- Add span tracking for welcome email with status codes
- Track email success/failure as span attributes
- Maintain existing breadcrumbs for debugging
- Preserve fire-and-forget behavior (no await on email send)

This provides visibility into email delivery without blocking
user registration/verification flows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add comprehensive Sentry span tracking around batch database
operations to monitor performance and catch failures.

- Wrap mark articles read/unread batch in Sentry span
- Wrap mark all articles read batch in Sentry span
- Track batch size, operation type, and user ID as attributes
- Capture exceptions with full context (batch size, user, article count)
- Set appropriate span status codes for success/failure

Batch operations are critical for user experience - this ensures
we're alerted to failures and can identify performance patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add Sentry span tracking for OpenGraph image extraction with
metrics emission for pattern analysis.

- Wrap OG image fetch in Sentry span with domain tracking
- Track success (found/not found) and failures separately
- Emit metrics counters for error patterns (by domain and error type)
- Set appropriate span status for found/not found/error cases
- Avoid spamming Sentry with every failure (use metrics instead)

This allows us to identify problematic domains and error patterns
without creating excessive noise in Sentry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Implement automatic retry for transient feed fetch failures and
add full transaction monitoring for OPML import operations.

Subscription Creation & Preview Enhancements:
- Add automatic retry logic for HTTP 502, 503, 504, 429 errors
- Implement exponential backoff (1s, 2s delays) with max 2 retries
- Track retry attempts in Sentry breadcrumbs and error context
- Enhanced error tagging with domain and HTTP status codes
- Track attempt count and last status code for debugging

OPML Import Transaction Monitoring:
- Wrap entire import in Sentry transaction span
- Create individual spans for each feed import
- Track feed-level attributes (URL, title, domain, filters, categories)
- Calculate and report success rate as span attribute
- Proper error boundaries with per-feed exception capture
- Handle limit reached and already subscribed cases gracefully
- Fix loop control flow to work within Sentry span callbacks

Benefits:
- Improved resilience against transient failures (network, server)
- Clear visibility into which feeds fail and why during bulk imports
- Domain-based error pattern analysis for subscription issues
- Success rate tracking for OPML import operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@KyleTryon KyleTryon requested a review from Copilot December 4, 2025 02:28
@KyleTryon
Copy link
Contributor Author

@sentry review

Comment on lines +426 to +430
{
name: "auth.login",
op: "auth.signin",
attributes: {
"auth.method": "username_password",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code uses await Sentry.startSpan() syntax on line 426, but Sentry's startSpan is not an async function and should not be awaited. The Sentry.startSpan() signature takes a callback that can be async, but the startSpan call itself is synchronous. The correct pattern is return Sentry.startSpan({...}, async (span) => {...}) without awaiting the startSpan call.
Severity: HIGH

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/api/src/routers/auth.ts#L426-L430

Potential issue: The code uses `await Sentry.startSpan()` syntax on line 426, but
Sentry's startSpan is not an async function and should not be awaited. The
`Sentry.startSpan()` signature takes a callback that can be async, but the startSpan
call itself is synchronous. The correct pattern is `return Sentry.startSpan({...}, async
(span) => {...})` without awaiting the startSpan call.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126

Comment on lines 229 to 245
error instanceof Error ? error.message : "Unknown error";
const errorStack = error instanceof Error ? error.stack : undefined;

// Add breadcrumb for exception
await Sentry.addBreadcrumb({
category: "email.service",
message: "Exception thrown while sending email",
level: "error",
data: {
emailType: type,
recipient: to,
errorMessage,
errorType: error instanceof Error ? error.name : "Unknown",
},
});

console.error(`Error sending ${type} email:`, {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary awaits on non-async breadcrumb calls. Lines 121-130, 142-152, 222-230, and 237-245 all contain await Sentry.addBreadcrumb(...). However, Sentry.addBreadcrumb() is a synchronous function and should not be awaited. Removing these awaits will improve performance and align with Sentry API usage patterns.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/api/src/services/email.ts#L121-L245

Potential issue: Unnecessary awaits on non-async breadcrumb calls. Lines 121-130,
142-152, 222-230, and 237-245 all contain `await Sentry.addBreadcrumb(...)`. However,
`Sentry.addBreadcrumb()` is a synchronous function and should not be awaited. Removing
these awaits will improve performance and align with Sentry API usage patterns.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126

Comment on lines +468 to +476
body: {
username: input.username,
password: input.password,
},
headers: authHeaders,
});

if (!dbUser) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found",
});
}
if (!result || !result.user) {
throw new TRPCError({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On line 468, parentSpan?.setAttributes() is called but the previous pattern throughout the codebase uses parentSpan?.setAttribute() (singular). This inconsistency may indicate a typo. Verify the correct Sentry API method name - it should likely be setAttributes() for setting multiple attributes at once, but the singular form is used elsewhere in the codebase.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/api/src/routers/auth.ts#L468-L476

Potential issue: On line 468, `parentSpan?.setAttributes()` is called but the previous
pattern throughout the codebase uses `parentSpan?.setAttribute()` (singular). This
inconsistency may indicate a typo. Verify the correct Sentry API method name - it should
likely be `setAttributes()` for setting multiple attributes at once, but the singular
form is used elsewhere in the codebase.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126

Comment on lines +1834 to +1840

// Check if source exists
// Normalize Reddit URLs to prevent duplicates across different domains
const normalizedFeedUrl = normalizeRedditUrl(feedInfo.url);

const existingSources = await ctx.db
.select()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OPML import span loop (lines 1879-2099), the loop uses for (const feedInfo of feedsToProcess) with a shouldStopImporting flag that gets set to true when the limit is reached. However, the shouldStopImporting variable is declared inside the Sentry.startSpan callback scope on line 1834, but it's set inside nested Sentry.startSpan callbacks (line 1932). This creates a scope issue where the outer loop's break condition might not work correctly. The variable should be declared in the outer scope before the loop.
Severity: HIGH

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/api/src/routers/subscriptions.ts#L1834-L1840

Potential issue: In the OPML import span loop (lines 1879-2099), the loop uses `for
(const feedInfo of feedsToProcess)` with a `shouldStopImporting` flag that gets set to
true when the limit is reached. However, the `shouldStopImporting` variable is declared
inside the `Sentry.startSpan` callback scope on line 1834, but it's set inside nested
`Sentry.startSpan` callbacks (line 1932). This creates a scope issue where the outer
loop's break condition might not work correctly. The variable should be declared in the
outer scope before the loop.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request enhances observability, security auditing, and reliability across authentication and subscription-related API endpoints by integrating Sentry instrumentation, implementing security event logging, and adding retry logic for transient failures.

Key Changes:

  • Added comprehensive Sentry spans and breadcrumbs across authentication flows (login, password reset, password change) and article operations for better observability and performance tracking
  • Integrated security audit logging for authentication events (successful/failed logins, password changes, resets) with IP address and user agent tracking
  • Implemented exponential backoff retry logic for transient HTTP errors (502, 503, 504, 429) when fetching RSS feeds

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/api/src/auth/security.ts Added extractHeaders utility function to normalize request headers from different formats (Headers object vs plain object)
packages/api/src/routers/auth.ts Wrapped login endpoint in Sentry span with detailed tracking; added security audit logging for login, password change, and password reset events with comprehensive error handling
packages/api/src/auth/better-auth.ts Enhanced email sending callbacks with Sentry spans and breadcrumbs; refactored header extraction to use new utility function for consistency
packages/api/src/routers/subscriptions.ts Implemented retry logic with exponential backoff for feed fetching in both create and preview endpoints; added Sentry instrumentation for OPML import with per-feed tracking
packages/api/src/routers/articles.ts Wrapped batch operations for marking articles read/unread in Sentry spans for performance monitoring and error tracking
packages/api/src/services/rss-fetcher.ts Added Sentry span around OpenGraph image extraction with detailed error tracking and metrics emission
packages/api/src/services/email.ts Enhanced email service with additional breadcrumbs at key points (before API call, on success, on error) for better debugging

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1066 to +1091
// Check if we should retry this error
const errorMessage = lastError.message;
const httpStatusMatch = errorMessage.match(/HTTP (\d+):/);
const httpStatus = httpStatusMatch ? parseInt(httpStatusMatch[1]) : 0;

if (!TRANSIENT_STATUS_CODES.includes(httpStatus)) {
// Non-transient error - don't retry
await Sentry.captureException(lastError, {
level: "error",
tags: {
operation: "subscription_preview_parse",
domain: domain || "unknown",
http_status: httpStatus.toString(),
attempts: (attempt + 1).toString(),
},
extra: {
url: input.url,
error_message: errorMessage,
},
});

throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch or parse feed: ${errorMessage}`,
});
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry logic has the same issue as in the create endpoint: when parseFeed() throws an error (e.g., invalid feed XML), the error message won't match the regex /HTTP (\d+):/, so httpStatus will be 0. Since 0 is not in TRANSIENT_STATUS_CODES, the code will throw immediately without retrying. However, this also means that even if the fetch succeeded but parsing failed, we're still inside the retry loop. The parsing errors should not be retried - only fetch errors should be. Consider checking if the error occurred during fetch vs parse, and only apply retry logic to fetch errors.

Copilot uses AI. Check for mistakes.
try {
await logSecurityEvent(ctx.db, {
userId,
action: "password_reset_success",
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action logged for a failed password reset should be "password_reset_failed" but it's currently set to "password_reset_success". This will cause failed password reset attempts to be logged as successful, which is incorrect and could lead to confusion in security auditing.

Suggested change
action: "password_reset_success",
action: "password_reset_failed",

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +116
headers[key.toLowerCase()] = Array.isArray(value)
? value[0]
: String(value);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When value is undefined, calling String(value) will convert it to the string "undefined" instead of keeping it as undefined. This could lead to unexpected behavior where headers with undefined values are treated as having the literal string "undefined" as their value. Consider checking for undefined explicitly: value === undefined ? undefined : (Array.isArray(value) ? value[0] : String(value)).

Suggested change
headers[key.toLowerCase()] = Array.isArray(value)
? value[0]
: String(value);
headers[key.toLowerCase()] = value === undefined
? undefined
: Array.isArray(value)
? value[0]
: String(value);

Copilot uses AI. Check for mistakes.
Comment on lines +415 to +441
// Check if we should retry this error
const errorMessage = lastError.message;
const httpStatusMatch = errorMessage.match(/HTTP (\d+):/);
const httpStatus = httpStatusMatch ? parseInt(httpStatusMatch[1]) : 0;

if (!TRANSIENT_STATUS_CODES.includes(httpStatus)) {
// Non-transient error - don't retry
await Sentry.captureException(lastError, {
level: "error",
tags: {
operation: "subscription_feed_parse",
domain: domain || "unknown",
http_status: httpStatus.toString(),
attempts: (attempt + 1).toString(),
},
extra: {
url: input.url,
user_id: userId,
error_message: errorMessage,
},
});

throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch or parse feed: ${errorMessage}`,
});
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry logic has a potential issue: when parseFeed() throws an error (e.g., invalid feed XML), the error message won't match the regex /HTTP (\d+):/, so httpStatus will be 0. Since 0 is not in TRANSIENT_STATUS_CODES, the code will throw immediately without retrying. However, this also means that even if the fetch succeeded but parsing failed, we're still inside the retry loop. The parsing errors should not be retried - only fetch errors should be. Consider checking if the error occurred during fetch vs parse, and only apply retry logic to fetch errors.

Copilot uses AI. Check for mistakes.
TRANSIENT_STATUS_CODES.includes(response.status) &&
attempt < MAX_RETRIES
) {
lastError = new Error(errorMessage);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value assigned to lastError here is unused.

Suggested change
lastError = new Error(errorMessage);

Copilot uses AI. Check for mistakes.
TRANSIENT_STATUS_CODES.includes(response.status) &&
attempt < MAX_RETRIES
) {
lastError = new Error(errorMessage);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value assigned to lastError here is unused.

Suggested change
lastError = new Error(errorMessage);

Copilot uses AI. Check for mistakes.
KyleTryon and others added 6 commits December 3, 2025 21:39
Fix critical bugs identified in code review:

1. Security Audit Logging (HIGH):
   - Fix incorrect action type for failed password resets
   - Changed "password_reset_success" to "password_reset_failed" in error handler
   - Added missing "password_reset_failed" to SecurityAction type

2. Retry Logic Separation (HIGH):
   - Separate fetch errors from parse errors in retry logic
   - Parse errors now throw immediately without retry
   - Only HTTP fetch errors (502, 503, 504, 429) are retried
   - Improves error classification: "fetch_error" vs "parse_error"
   - Applied to both create and preview endpoints

3. Header Extraction (MEDIUM):
   - Fix String(undefined) converting undefined to "undefined" string
   - Preserve undefined values correctly in header extraction
   - Prevents confusion where missing headers appear as "undefined"

4. Code Cleanup (LOW):
   - Remove unused lastError assignments in retry loops
   - Assignments were made but value never used before continue

All changes verified with 819 passing tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add explicit paths for packages/api/wrangler.toml files since
root-level patterns don't match subdirectories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Admin dashboard was missing emailVerified field, causing unverified
users to appear as "active" when they weren't. This adds:
- emailVerified field to AdminUserSchema
- emailVerified filter option for queries
- Metrics tracking for when filter is used

Fixes user status discrepancy where unverified users showed as active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Security audit logging was completely non-functional due to missing
SQL default on created_at column. This caused silent failures:

Root cause:
- security_audit_log.created_at was NOT NULL without SQL DEFAULT
- Drizzle's $defaultFn() only works at app level, not in database
- logSecurityEvent() didn't provide createdAt value
- All insert attempts failed silently (caught and logged to console)
- Result: Zero audit logs ever written to production

Fix (3-part):
1. Quick fix: logSecurityEvent() now explicitly provides createdAt
2. Schema fix: Changed to SQL DEFAULT using unixepoch()
3. Migration: 0006_fix_security_audit_log_default.sql adds DEFAULT to DB

Impact:
- Enables tracking of login attempts, password resets, registrations
- Critical for security monitoring and compliance
- Consistent with other tables using same timestamp pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
While .claude/ directory is gitignored, settings.json should be
committed to share team-wide Claude Code configurations like
security policies and approved tool permissions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add shared Claude Code configuration with:
- Security deny rules for .env, secrets, production operations
- Broader allow patterns for common workflows
- Git co-authoring enabled
- MCP server integrations (Sentry, Better Auth, Shadcn)

Establishes team-wide safety guardrails and tool permissions
for AI-assisted development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +507 to +519
const headers = extractHeaders(ctx.req.headers);
const ipAddress = getClientIp(headers);
const userAgent = getUserAgent(headers);

try {
await logSecurityEvent(ctx.db, {
userId,
action: "login_success",
ipAddress,
userAgent,
success: true,
});
} catch (auditError) {
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header extraction and IP/user agent retrieval logic (lines 507-509) is duplicated at lines 608-610. Similarly, this pattern is repeated at lines 867-868, 884-886, 1061-1062, and 1078-1080. Consider extracting this into a helper function to reduce duplication and improve maintainability.

Suggested change
const headers = extractHeaders(ctx.req.headers);
const ipAddress = getClientIp(headers);
const userAgent = getUserAgent(headers);
try {
await logSecurityEvent(ctx.db, {
userId,
action: "login_success",
ipAddress,
userAgent,
success: true,
});
} catch (auditError) {
const { headers, ipAddress, userAgent } = getRequestClientInfo(ctx.req.headers, { getClientIp, getUserAgent, extractHeaders });

Copilot uses AI. Check for mistakes.
Comment on lines +353 to +502
Sentry.startSpan(
{
op: "email.verification",
name: "Send Verification Email (Fire-and-Forget)",
attributes: {
user_id: user.id,
user_email: user.email,
fire_and_forget: true,
},
extra: {
userEmail: user.email,
userId: user.id,
},
level: "error",
});
},
async (span) => {
// Add breadcrumb for email sending attempt
await Sentry.addBreadcrumb({
category: "email",
message: "Sending verification email",
level: "info",
data: {
userEmail: user.email,
userId: user.id,
emailType: "verification",
},
});

console.error("Failed to send verification email:", {
userEmail: user.email,
userId: user.id,
error: error instanceof Error ? error.message : String(error),
});
});
sendVerificationEmail(env, {
to: user.email,
username:
(userWithPlugins.username as string | undefined) ||
user.name ||
"User",
verificationToken: token,
verificationUrl: frontendVerificationUrl, // Frontend URL instead of backend URL
})
.then(async (emailResult) => {
// Track result in span
span?.setAttribute("email.success", emailResult.success);

// Check if email sending failed
if (!emailResult.success) {
span?.setAttribute(
"email.error",
emailResult.error || "Unknown error"
);
span?.setStatus({ code: 2, message: "email failed" });

// Add breadcrumb for failure
await Sentry.addBreadcrumb({
category: "email",
message: "Verification email failed",
level: "error",
data: {
userEmail: user.email,
userId: user.id,
error: emailResult.error,
emailType: "verification",
},
});

// Log critical email failures to Sentry
await Sentry.captureException(
new Error(
emailResult.error ||
"Failed to send verification email"
),
{
tags: {
component: "better-auth",
operation: "email-verification",
email_type: "verification",
},
extra: {
userEmail: user.email,
userId: user.id,
errorMessage: emailResult.error,
},
level: "error",
}
);

console.error("Failed to send verification email:", {
userEmail: user.email,
userId: user.id,
error: emailResult.error,
});
} else {
span?.setStatus({ code: 1, message: "ok" });

// Add breadcrumb for success
await Sentry.addBreadcrumb({
category: "email",
message: "Verification email sent successfully",
level: "info",
data: {
userEmail: user.email,
userId: user.id,
emailType: "verification",
},
});
}
})
.catch(async (error) => {
span?.setAttribute(
"email.exception",
error instanceof Error ? error.message : String(error)
);
span?.setStatus({ code: 2, message: "exception" });

// Add breadcrumb for unexpected error
await Sentry.addBreadcrumb({
category: "email",
message: "Unexpected error sending verification email",
level: "error",
data: {
userEmail: user.email,
userId: user.id,
error:
error instanceof Error
? error.message
: String(error),
emailType: "verification",
},
});

// Log unexpected exceptions (e.g., network errors, timeouts)
await Sentry.captureException(error, {
tags: {
component: "better-auth",
operation: "email-verification",
email_type: "verification",
},
extra: {
userEmail: user.email,
userId: user.id,
},
level: "error",
});

console.error(
"Unexpected error sending verification email:",
{
userEmail: user.email,
userId: user.id,
error:
error instanceof Error
? error.message
: String(error),
}
);
});
}
);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Sentry span for sending verification email is started without awaiting its completion (fire-and-forget pattern). While this is intentional for non-blocking email operations, the span is started but never properly awaited, which means the span tracking may not complete before the function returns. Consider using Sentry.startSpan(...).then(...) pattern or documenting this behavior more clearly to avoid confusion about incomplete spans.

Copilot uses AI. Check for mistakes.
Comment on lines +1838 to 2109
for (const feedInfo of feedsToProcess) {
if (shouldStopImporting) {
break; // Stop processing remaining feeds if limit reached
}

if (existingSubscription.length > 0) {
// Already subscribed, skip
successCount++;
continue;
}
// Create a span for each feed import
await Sentry.startSpan(
{
op: "opml.import_feed",
name: `Import Feed: ${feedInfo.title}`,
attributes: {
"feed.url": feedInfo.url,
"feed.title": feedInfo.title,
"feed.domain": extractDomain(feedInfo.url) || "unknown",
"feed.has_filters": feedInfo.filters
? feedInfo.filters.length > 0
: false,
"feed.categories_count": feedInfo.categories.length,
},
},
async (feedSpan) => {
try {
// Fetch and validate feed
const response = await fetch(feedInfo.url, {
headers: {
"User-Agent": "TuvixRSS/1.0",
Accept:
"application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
},
signal: AbortSignal.timeout(15000),
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

// Check source limit before creating subscription
const limitCheck = await checkSourceLimit(ctx.db, userId);
if (!limitCheck.allowed) {
// Limit reached, stop importing
errorCount++;
errors.push({
url: feedInfo.url,
error: `Source limit reached (${limitCheck.limit}/${limitCheck.limit}). Remaining feeds not imported.`,
});
break; // Stop processing remaining feeds
}
const feedContent = await response.text();
const feedResult = parseFeed(feedContent);
const feedData = feedResult.feed;

// Extract metadata
const feedTitle =
"title" in feedData && feedData.title
? feedData.title
: feedInfo.title;
const feedDescription =
"description" in feedData && feedData.description
? stripHtml(feedData.description)
: "subtitle" in feedData && feedData.subtitle
? stripHtml(feedData.subtitle)
: undefined;
const siteUrl =
"link" in feedData && feedData.link
? feedData.link
: "links" in feedData &&
Array.isArray(feedData.links) &&
feedData.links[0]?.href
? feedData.links[0].href
: undefined;

// Check if source exists
// Normalize Reddit URLs to prevent duplicates across different domains
const normalizedFeedUrl = normalizeRedditUrl(feedInfo.url);

const existingSources = await ctx.db
.select()
.from(schema.sources)
.where(eq(schema.sources.url, normalizedFeedUrl))
.limit(1);

let sourceId: number;

if (existingSources.length > 0) {
sourceId = existingSources[0].id;
await ctx.db
.update(schema.sources)
.set({
title: feedTitle,
description: feedDescription,
siteUrl,
lastFetched: new Date(),
})
.where(eq(schema.sources.id, sourceId));
} else {
const newSource = await ctx.db
.insert(schema.sources)
.values({
url: normalizedFeedUrl,
title: feedTitle,
description: feedDescription,
siteUrl,
iconType: "auto",
lastFetched: new Date(),
})
.returning();
sourceId = newSource[0].id;
}

// Determine filter settings from OPML data
const filterEnabled =
feedInfo.filterEnabled !== undefined
? feedInfo.filterEnabled
: feedInfo.filters && feedInfo.filters.length > 0
? true
: false;
const filterMode = feedInfo.filterMode || "include";

// Create subscription
const newSubscription = await ctx.db
.insert(schema.subscriptions)
.values({
userId,
sourceId,
customTitle: null,
filterEnabled,
filterMode,
})
.returning();

const subscriptionId = newSubscription[0].id;

// Create/link categories (using normalization helper to prevent duplicates)
if (feedInfo.categories.length > 0) {
// Track category IDs to prevent duplicate links
const linkedCategoryIds = new Set<number>();

for (const categoryName of feedInfo.categories) {
// Use findOrCreateCategory for case-insensitive normalization
const categoryId = await findOrCreateCategory(
ctx.db,
schema.categories,
userId,
categoryName,
generateColorFromString
);

// Only link if we haven't already linked this category
if (!linkedCategoryIds.has(categoryId)) {
linkedCategoryIds.add(categoryId);
await ctx.db.insert(schema.subscriptionCategories).values({
subscriptionId,
categoryId,
});
}
}
}
// Check if already subscribed
const existingSubscription = await ctx.db
.select()
.from(schema.subscriptions)
.where(
and(
eq(schema.subscriptions.userId, userId),
eq(schema.subscriptions.sourceId, sourceId)
)
)
.limit(1);

if (existingSubscription.length > 0) {
// Already subscribed, skip
successCount++;

// Mark span as successful (already exists)
feedSpan.setAttribute("feed.status", "already_exists");
feedSpan.setStatus({
code: 1,
message: "already subscribed",
});
return; // Return from span callback (equivalent to continue)
}

// Create filters if provided
if (feedInfo.filters && feedInfo.filters.length > 0) {
for (const filter of feedInfo.filters) {
try {
// Validate regex pattern if matchType is 'regex'
if (filter.matchType === "regex") {
try {
new RegExp(filter.pattern);
} catch (regexError) {
// Invalid regex - skip this filter but continue
// Check source limit before creating subscription
const limitCheck = await checkSourceLimit(ctx.db, userId);
if (!limitCheck.allowed) {
// Limit reached, stop importing
errorCount++;
errors.push({
url: feedInfo.url,
error: `Invalid regex pattern in filter: ${regexError instanceof Error ? regexError.message : "Unknown error"}`,
error: `Source limit reached (${limitCheck.limit}/${limitCheck.limit}). Remaining feeds not imported.`,
});

// Mark span as failed due to limit
feedSpan.setAttribute("feed.status", "limit_reached");
feedSpan.setStatus({
code: 2,
message: "source limit reached",
});
continue;

shouldStopImporting = true; // Signal to stop processing remaining feeds
return; // Return from span callback
}

// Determine filter settings from OPML data
const filterEnabled =
feedInfo.filterEnabled !== undefined
? feedInfo.filterEnabled
: feedInfo.filters && feedInfo.filters.length > 0
? true
: false;
const filterMode = feedInfo.filterMode || "include";

// Create subscription
const newSubscription = await ctx.db
.insert(schema.subscriptions)
.values({
userId,
sourceId,
customTitle: null,
filterEnabled,
filterMode,
})
.returning();

const subscriptionId = newSubscription[0].id;

// Create/link categories (using normalization helper to prevent duplicates)
if (feedInfo.categories.length > 0) {
// Track category IDs to prevent duplicate links
const linkedCategoryIds = new Set<number>();

for (const categoryName of feedInfo.categories) {
// Use findOrCreateCategory for case-insensitive normalization
const categoryId = await findOrCreateCategory(
ctx.db,
schema.categories,
userId,
categoryName,
generateColorFromString
);

// Only link if we haven't already linked this category
if (!linkedCategoryIds.has(categoryId)) {
linkedCategoryIds.add(categoryId);
await ctx.db
.insert(schema.subscriptionCategories)
.values({
subscriptionId,
categoryId,
});
}
}
}
}

// Insert filter directly (more efficient than API call)
await ctx.db.insert(schema.subscriptionFilters).values({
subscriptionId,
field: filter.field,
matchType: filter.matchType,
pattern: filter.pattern,
caseSensitive: filter.caseSensitive,
});
} catch (filterError) {
// Filter creation failed - log error but continue
errors.push({
url: feedInfo.url,
error: `Failed to create filter: ${filterError instanceof Error ? filterError.message : "Unknown error"}`,
});
// Continue with next filter
// Create filters if provided
if (feedInfo.filters && feedInfo.filters.length > 0) {
for (const filter of feedInfo.filters) {
try {
// Validate regex pattern if matchType is 'regex'
if (filter.matchType === "regex") {
try {
new RegExp(filter.pattern);
} catch (regexError) {
// Invalid regex - skip this filter but continue
errors.push({
url: feedInfo.url,
error: `Invalid regex pattern in filter: ${regexError instanceof Error ? regexError.message : "Unknown error"}`,
});
continue;
}
}

// Insert filter directly (more efficient than API call)
await ctx.db.insert(schema.subscriptionFilters).values({
subscriptionId,
field: filter.field,
matchType: filter.matchType,
pattern: filter.pattern,
caseSensitive: filter.caseSensitive,
});
} catch (filterError) {
// Filter creation failed - log error but continue
errors.push({
url: feedInfo.url,
error: `Failed to create filter: ${filterError instanceof Error ? filterError.message : "Unknown error"}`,
});
// Continue with next filter
}
}
}

successCount++;

// Mark span as successful
feedSpan.setAttribute("feed.status", "success");
feedSpan.setStatus({ code: 1, message: "ok" });
} catch (error) {
errorCount++;
const errorMessage =
error instanceof Error ? error.message : "Unknown error";

errors.push({
url: feedInfo.url,
error: errorMessage,
});

// Mark span as failed
feedSpan.setAttribute("feed.status", "error");
feedSpan.setAttribute("feed.error", errorMessage);
feedSpan.setStatus({
code: 2,
message: "feed import failed",
});

// Capture exception for this specific feed
await Sentry.captureException(error, {
level: "warning",
tags: {
operation: "opml_import_feed",
domain: extractDomain(feedInfo.url) || "unknown",
},
extra: {
feed_url: feedInfo.url,
feed_title: feedInfo.title,
user_id: userId,
},
});
}
}
}
);
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OPML import loop processes feeds sequentially and creates a Sentry span for each feed. For large OPML files with many feeds, this could result in a very long operation with many spans. Consider adding a progress indicator or implementing batch processing with concurrent feed fetches (with appropriate rate limiting) to improve performance and user experience.

Copilot uses AI. Check for mistakes.
userAgent: params.userAgent,
metadata: params.metadata ? JSON.stringify(params.metadata) : undefined,
success: params.success,
createdAt: new Date(),
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createdAt field is being manually set to new Date() in the logSecurityEvent function, but the database schema now has a SQL-level default for this column. This manual assignment is redundant and could potentially cause issues if the application's timestamp differs from the database server's timestamp. Consider removing this explicit assignment and relying on the database default for consistency.

Suggested change
createdAt: new Date(),

Copilot uses AI. Check for mistakes.
TRANSIENT_STATUS_CODES.includes(response.status) &&
attempt < MAX_RETRIES
) {
continue; // Retry
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry loop uses continue to retry on transient errors, but this skips the rest of the loop iteration without properly handling the error. After continue, the outer catch block at line 410 won't be reached for this iteration, meaning lastError won't be set. If all retries fail with transient errors using continue, the code could exit the loop without feedData being set and without proper error tracking. Consider throwing an error or using a different control flow pattern.

Suggested change
continue; // Retry
lastError = new Error(errorMessage);
continue;

Copilot uses AI. Check for mistakes.
if (
TRANSIENT_STATUS_CODES.includes(response.status) &&
attempt < MAX_RETRIES
) {
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in the feed subscription endpoint: using continue to retry on transient errors can skip error tracking. If all retries result in continue statements, the loop will exit without feedData being set and without lastError being populated, potentially leading to the fallback error at line 1159 with an unhelpful message.

Suggested change
) {
) {
lastError = new Error(errorMessage); // Track last error for fallback

Copilot uses AI. Check for mistakes.
Comment on lines +305 to +308
// Retry logic for transient failures
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
const TRANSIENT_STATUS_CODES = [502, 503, 504, 429];
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry configuration (MAX_RETRIES, RETRY_DELAY_MS, TRANSIENT_STATUS_CODES) is duplicated across multiple endpoints in this file (lines 305-308, 995-997). Consider extracting these constants to a shared location at the top of the file or in a separate constants module to maintain consistency and ease future updates.

Copilot uses AI. Check for mistakes.
Comment on lines +500 to +505
const {
logSecurityEvent,
getClientIp,
getUserAgent,
extractHeaders,
} = await import("@/auth/security");
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security module imports (logSecurityEvent, getClientIp, getUserAgent, extractHeaders) are duplicated at lines 500-505 and 601-606 within the same function scope. These could be imported once at the beginning of the outer function scope to reduce redundancy and improve code maintainability.

Copilot uses AI. Check for mistakes.
KyleTryon and others added 4 commits December 4, 2025 00:20
Improvements from code review:
- Extract getRequestMetadata() helper to reduce header extraction duplication
- Consolidate security module imports in auth router
- Extract retry constants (MAX_RETRIES, RETRY_DELAY_MS, TRANSIENT_STATUS_CODES) to module top
- Add clarifying comment for createdAt defense-in-depth pattern

No functional changes, just improved maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Import logSecurityEvent and getRequestMetadata in login handler
- Add emailVerified field to admin listUsers and getUser responses
- Fixes type errors from refactoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Dynamic imports lose type information, causing ESLint to treat
destructured values as 'error' types. Added explicit type annotations
to resolve "unsafe assignment" and "unsafe call" linting errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
KyleTryon and others added 5 commits December 4, 2025 01:05
Type assertions (as) are more effective than type annotations (:) for
dynamic imports, as they assert the return type of the function call
rather than just the destructured variables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove dynamic imports of security module to fix type inference
- Add explicit type annotations to destructured variables
- Use top-level import instead of await import() pattern

This resolves ESLint errors where dynamic imports lose type information,
causing getRequestMetadata to be treated as 'error' type.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add top-level import for Sentry module
- Add nullish coalescing for optional olderThanDays attribute

Resolves ESLint errors from missing Sentry import and TypeScript
errors from passing undefined to Sentry span attributes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add nullish coalescing for extractDomain() calls
- Prevents TypeScript errors when domain extraction returns null

Sentry span attributes require string | number | boolean types,
not null or undefined.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…ements

- Add --> statement-breakpoint markers for Drizzle compatibility
- Prevents "more than one statement" error in test migrations
- Maintains same logic, just properly formatted for migration runner

This fixes test failures caused by better-sqlite3 rejecting
multi-statement SQL strings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +53 to +55
// Explicitly set createdAt for consistency with app time
// DB has SQL DEFAULT but we set it here for defense-in-depth
createdAt: new Date(),
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createdAt field is being explicitly set in the application code (line 55), but the database schema also has a SQL DEFAULT (line 461). This creates a redundancy issue:

  1. The schema default uses unixepoch('subsecond') * 1000 which is milliseconds since epoch
  2. The application code uses new Date() which creates a JavaScript Date object

When both are present, the application value will override the database default, but this defeats the purpose of having a database-level default. The comment mentions "defense-in-depth" but this is unnecessary complexity.

Recommendation: Either:

  • Remove the explicit createdAt: new Date() from the insert statement (preferred - rely on DB default)
  • Or remove the .default() from the schema if you want application-level control

Since the PR is specifically adding a DB-level SQL default (migration 0006), the application-level setting should be removed.

Suggested change
// Explicitly set createdAt for consistency with app time
// DB has SQL DEFAULT but we set it here for defense-in-depth
createdAt: new Date(),

Copilot uses AI. Check for mistakes.
Comment on lines +381 to +408
} catch (parseError) {
// Parse error - capture and throw immediately (don't retry)
const errorMessage =
parseError instanceof Error
? parseError.message
: "Failed to parse feed";

await Sentry.captureException(parseError, {
level: "error",
tags: {
operation: "subscription_feed_parse",
domain: domain || "unknown",
error_type: "parse_error",
attempts: (attempt + 1).toString(),
},
extra: {
url: input.url,
user_id: userId,
error_message: errorMessage,
fetch_status: lastStatusCode,
},
});

throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to parse feed: ${errorMessage}`,
});
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parse error handling has a critical bug. When a parse error is thrown at line 404-407, it's caught by the outer catch (error) block at line 409, which is labeled "only for FETCH errors now" (line 410).

This means:

  1. Parse errors will be incorrectly treated as fetch errors
  2. The code will attempt to retry parse errors if they occur on attempts < MAX_RETRIES
  3. The error handling logic (lines 414-471) will incorrectly apply HTTP status code retry logic to parse errors

Fix: The throw new TRPCError at lines 404-407 should be changed to immediately exit the function, not be caught by the outer catch block. You can:

  • Move the parse error throw outside of the try-catch structure
  • Or use a flag to indicate parse error and check it before entering retry logic
  • Or rethrow the TRPCError specifically in the outer catch block

Copilot uses AI. Check for mistakes.
});
}
} catch (error) {
// This catch block is only for FETCH errors now
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same critical bug as in the feed subscription endpoint: Parse errors thrown at line 1080-1083 will be caught by the outer catch (error) block at line 1085, which is intended "only for FETCH errors" (line 1086).

This causes parse errors to be incorrectly handled as fetch errors and potentially retried when they shouldn't be. The fix is the same as for the subscription endpoint - ensure TRPCError throws escape the retry loop immediately.

Suggested change
// This catch block is only for FETCH errors now
// This catch block is only for FETCH errors now
if (error instanceof TRPCError) {
throw error;
}

Copilot uses AI. Check for mistakes.
KyleTryon and others added 2 commits December 4, 2025 11:02
Security audit logging:
- Remove redundant createdAt field setting in application code
- Rely solely on SQL DEFAULT from database schema

Subscription error handling:
- Add TRPCError rethrow guards in catch blocks
- Prevents parse errors from being incorrectly retried as fetch errors
- Ensures parse errors exit immediately without retry logic

All three issues identified by Copilot AI have been resolved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Merge executeBatch import with existing @/db/utils imports
- Group @/db/* imports together for better organization
- Remove duplicate import statement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@KyleTryon KyleTryon merged commit 0ebdff1 into dev Dec 4, 2025
@KyleTryon KyleTryon deleted the observability/signup branch December 5, 2025 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants