-
Notifications
You must be signed in to change notification settings - Fork 3
added error monitoring and observability using Sentry #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| name: CI | ||
|
|
||
| on: [push, pull_request] | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 18 | ||
| cache: 'npm' | ||
| - name: Install dependencies | ||
| run: npm ci | ||
| - name: Lint | ||
| run: npm run lint --if-present | ||
| - name: Build / Typecheck | ||
| run: npm run build --if-present | ||
| - name: Run tests | ||
| run: npm test --if-present |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { getCanonicalInfo } from '@/lib/canonical' | |||||
| import { getOrGenerateProfileSnapshot } from '@/lib/profile-snapshot' | ||||||
| import { calculateNarrativeMomentum, calculatePulseIndex, generateSentimentHistoricalData, generateEnhancedSentimentAnalysis } from '@/lib/analysis-service' | ||||||
| import { normalizePublishedDate } from '@/lib/utils' | ||||||
| import Sentry, { flush as sentryFlush } from '@/lib/sentry' | ||||||
|
|
||||||
| // Emit SSE event helper | ||||||
| function sseChunk(event: string, data: unknown) { | ||||||
|
|
@@ -71,6 +72,7 @@ export async function GET(req: Request) { | |||||
| const raw = searchParams.get('domain') | ||||||
| if (!raw) return NextResponse.json({ error: 'Missing domain' }, { status: 400 }) | ||||||
| const domain = cleanDomain(raw) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'briefing_stream_start', data: { domain } }) | ||||||
| // Dynamic canonical inference structure | ||||||
| let canonical: { canonicalName: string; aliases: string[]; industryHint?: string; brandTokens?: string[] } | null = null | ||||||
| const refreshProfile = searchParams.get('refreshProfile') === 'true' | ||||||
|
|
@@ -115,12 +117,18 @@ export async function GET(req: Request) { | |||||
| try { | ||||||
| // Stage 0: Canonical inference (disambiguation & alias surface) | ||||||
| try { | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'canonical_inference_start' }) | ||||||
| canonical = await getCanonicalInfo(domain, llmProviders) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'canonical_inference_done', data: { canonical } }) | ||||||
| send('canonical', { canonical }) | ||||||
| } catch {} | ||||||
| } catch (e) { | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'canonical_inference_failed' }) | ||||||
| } | ||||||
|
|
||||||
| // Stage 1: Overview (fast LLM TL;DR) | ||||||
| const snapshotProfile = await getOrGenerateProfileSnapshot(domain, llmProviders, refreshProfile) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'profile_snapshot_start' }) | ||||||
| const snapshotProfile = await getOrGenerateProfileSnapshot(domain, llmProviders, refreshProfile) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'profile_snapshot_done' }) | ||||||
| // Attach canonical enrichment: only override if snapshot name looks like raw domain stem or is very short (<4 chars) | ||||||
| if (canonical) { | ||||||
| const baseStem = domain.split('.')[0].toLowerCase() | ||||||
|
|
@@ -219,12 +227,24 @@ Return ONLY valid JSON array: | |||||
|
|
||||||
| return []; | ||||||
| } catch (error) { | ||||||
| try { | ||||||
| Sentry.withScope((scope: any) => { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
|
||||||
| scope.setTag('sse_subtask', 'founders_fetch') | ||||||
| scope.setExtra('domain', domain) | ||||||
| Sentry.captureException(error) | ||||||
| }) | ||||||
| // flush so short-lived events have a chance to be delivered | ||||||
| await sentryFlush(1500) | ||||||
| } catch (e) {} | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This
Suggested change
|
||||||
| console.error('Error fetching founder info:', error); | ||||||
| return []; | ||||||
| } | ||||||
| })() | ||||||
|
|
||||||
| const profilePromise = safeGenerateJson(profilePrompt, llmProviders).catch(() => ({ name: domain.split('.')[0], description: `Company at ${domain}`, ipoStatus: 'Unknown', socials: {} })) | ||||||
| const profilePromise = safeGenerateJson(profilePrompt, llmProviders).catch((err: any) => { | ||||||
| try { Sentry.addBreadcrumb({ category: 'sse', message: 'profile_prompt_failed' }); Sentry.captureException(err) } catch (e) {} | ||||||
| return ({ name: domain.split('.')[0], description: `Company at ${domain}`, ipoStatus: 'Unknown', socials: {} }) | ||||||
| }) | ||||||
|
|
||||||
| const [verifiedFounders, profile] = await Promise.all([foundersPromise, profilePromise]) | ||||||
| // Split founders vs execs for emission | ||||||
|
|
@@ -238,6 +258,7 @@ Return ONLY valid JSON array: | |||||
| let mainMentions = await fetchExaData(domain, 'mentions', exaKey, { numResults: 25 }) | ||||||
| // Stage 3: Competitor discovery (LLM) + mentions for main company | ||||||
| const competitors = await discoverCompetitors(domain, llmProviders) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'competitor_discovery_done', data: { competitors } }) | ||||||
| send('competitors', { competitors }) | ||||||
| // Re-score + disambiguation filter: keep only items referencing aliases unless from trusted source | ||||||
| const TRUSTED_SOURCES = Array.from(TRUSTED_SOURCES_SET) | ||||||
|
|
@@ -396,8 +417,19 @@ NEWS: ${JSON.stringify(mainTopNews.slice(0,4))}` | |||||
|
|
||||||
| // End | ||||||
| send('done', { ok: true }) | ||||||
| Sentry.addBreadcrumb({ category: 'sse', message: 'stream_done' }) | ||||||
| controller.close() | ||||||
| } catch (err: any) { | ||||||
| // Capture and flush to improve delivery chance for short-lived processes | ||||||
| try { | ||||||
| Sentry.withScope((scope: any) => { | ||||||
| scope.setTag('route', 'briefing/stream') | ||||||
| scope.setExtra('domain', domain) | ||||||
| scope.setExtra('llmProviders', llmProviders.map(p => p.provider)) | ||||||
| Sentry.captureException(err) | ||||||
| }) | ||||||
| await sentryFlush(2000) | ||||||
| } catch (e) {} | ||||||
| send('error', { message: err?.message || 'Unknown error' }) | ||||||
| controller.close() | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import OpenAI from 'openai'; | |||||||||||||||
| import { GoogleGenerativeAI } from '@google/generative-ai'; | ||||||||||||||||
| import Groq from 'groq-sdk'; | ||||||||||||||||
| import { sharedLimiter } from '@/lib/limiter'; | ||||||||||||||||
| import Sentry from '@/lib/sentry' | ||||||||||||||||
|
|
||||||||||||||||
| export type LlmProvider = 'openai' | 'gemini' | 'groq'; | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -24,6 +25,7 @@ async function generateText( | |||||||||||||||
| switch (provider) { | ||||||||||||||||
| case 'groq': | ||||||||||||||||
| return await sharedLimiter.schedule(async () => { | ||||||||||||||||
| Sentry.addBreadcrumb({ category: 'llm', message: 'calling groq', data: { model } }) | ||||||||||||||||
| const groq = new Groq({ apiKey }); | ||||||||||||||||
| const groqResponse = await groq.chat.completions.create({ | ||||||||||||||||
| messages: [{ role: 'user', content: prompt }], | ||||||||||||||||
|
|
@@ -34,6 +36,7 @@ async function generateText( | |||||||||||||||
| }); | ||||||||||||||||
| case 'openai': | ||||||||||||||||
| return await sharedLimiter.schedule(async () => { | ||||||||||||||||
| Sentry.addBreadcrumb({ category: 'llm', message: 'calling openai', data: { model } }) | ||||||||||||||||
| const openai = new OpenAI({ apiKey }); | ||||||||||||||||
| const openaiResponse = await openai.chat.completions.create({ | ||||||||||||||||
| messages: [{ role: 'user', content: prompt }], | ||||||||||||||||
|
|
@@ -45,6 +48,7 @@ async function generateText( | |||||||||||||||
|
|
||||||||||||||||
| case 'gemini': | ||||||||||||||||
| return await sharedLimiter.schedule(async () => { | ||||||||||||||||
| Sentry.addBreadcrumb({ category: 'llm', message: 'calling gemini', data: { model } }) | ||||||||||||||||
| const genAI = new GoogleGenerativeAI(apiKey); | ||||||||||||||||
| const geminiModel = genAI.getGenerativeModel({ | ||||||||||||||||
| model: model || 'gemini-2.0-flash', | ||||||||||||||||
|
|
@@ -58,6 +62,17 @@ async function generateText( | |||||||||||||||
| throw new Error(`Unsupported LLM provider: ${provider}`); | ||||||||||||||||
| } | ||||||||||||||||
| } catch (error: any) { | ||||||||||||||||
| // Capture provider errors with contextual extra data | ||||||||||||||||
| try { | ||||||||||||||||
| Sentry.withScope(scope => { | ||||||||||||||||
| scope.setTag('llmProvider', provider) | ||||||||||||||||
| scope.setExtra('model', model) | ||||||||||||||||
| scope.setExtra('promptSnippet', typeof prompt === 'string' ? prompt.slice(0, 200) : null) | ||||||||||||||||
| Sentry.captureException(error) | ||||||||||||||||
| }) | ||||||||||||||||
| } catch (e) { | ||||||||||||||||
| // swallow any Sentry failures | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+73
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This
Suggested change
|
||||||||||||||||
| console.error(`Error with ${provider}:`, error); | ||||||||||||||||
| throw new Error(`Failed to generate text with ${provider}: ${error.message}`); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import * as Sentry from '@sentry/node' | ||
|
|
||
| const DSN = process.env.SENTRY_DSN || '' | ||
| const ENV = process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'development' | ||
| const RELEASE = process.env.SENTRY_RELEASE | ||
|
|
||
| let initialized = false | ||
|
|
||
| export function initSentry() { | ||
| if (initialized) return | ||
| if (!DSN) { | ||
| // No-op if DSN not provided — keep API surface available for calls | ||
| // so instrumentation can remain in code without throwing. | ||
| // eslint-disable-next-line no-console | ||
| console.warn('[sentry] SENTRY_DSN not set — Sentry disabled') | ||
| initialized = true | ||
| return | ||
| } | ||
|
|
||
| Sentry.init({ | ||
| dsn: DSN, | ||
| environment: ENV, | ||
| release: RELEASE, | ||
| // Leave traces disabled by default; enable explicitly if desired. | ||
| tracesSampleRate: 0.0, | ||
| }) | ||
| initialized = true | ||
| } | ||
|
|
||
| // Initialize on import so routes can simply import Sentry and use it. | ||
| initSentry() | ||
|
|
||
| export default Sentry | ||
|
|
||
| export const flush = (timeout = 2000) => { | ||
| try { | ||
| return Sentry.flush(timeout) | ||
| } catch (e) { | ||
| // If Sentry isn't initialised or flushing fails, swallow the error. | ||
| // eslint-disable-next-line no-console | ||
| console.warn('[sentry] flush failed', e) | ||
| return Promise.resolve(false) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this
catchblock, a breadcrumb forcanonical_inference_failedis added, but the actual erroreis not captured and sent to Sentry. This means you'll know that this step failed, but you won't have the error details (like stack trace or message) to debug why it failed. This significantly reduces the value of the error monitoring for this part of the code.