Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
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
20 changes: 20 additions & 0 deletions exora/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,25 @@ Canonical inference guides alias filtering, disambiguates homonyms, and drives e
- No logging of raw keys
- All enrichment transient per request

## Continuous Integration (CI)

This repository includes a GitHub Actions workflow that runs on push and pull request to enforce quality checks.

- What it runs: dependency install, linter (if present), build/typecheck (if present), and tests (if present).
- Workflow file: `.github/workflows/ci.yml`.

To run the same checks locally use:

```powershell
npm ci
npm run lint # if present
npm run build # if present (often performs TypeScript typechecking)
npm test # if present
```

If any of those scripts are missing the workflow currently uses `--if-present` so CI will not fail solely because a script is not defined. If you'd prefer stricter behavior (fail when scripts are missing), update the workflow to remove `--if-present`.


---
## 🖥️ Local Development
```bash
Expand Down Expand Up @@ -170,3 +189,4 @@ Consider further enhancement with a future compact mode (reduced padding + conde
---
## �📬 Contact
For questions or collaboration: open an issue or reach out via GitHub profile.

38 changes: 35 additions & 3 deletions exora/app/api/briefing/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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' })
}
Comment on lines +124 to +126
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In this catch block, a breadcrumb for canonical_inference_failed is added, but the actual error e is 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.

Suggested change
} catch (e) {
Sentry.addBreadcrumb({ category: 'sse', message: 'canonical_inference_failed' })
}
} catch (e) {
Sentry.captureException(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()
Expand Down Expand Up @@ -219,12 +227,24 @@ Return ONLY valid JSON array:

return [];
} catch (error) {
try {
Sentry.withScope((scope: any) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using scope: any bypasses TypeScript's type safety. It's better to let TypeScript infer the type by removing : any. This also applies to a similar usage on line 425. For even better type safety, you could explicitly type it by importing Scope from @sentry/node.

Suggested change
Sentry.withScope((scope: any) => {
Sentry.withScope((scope) => {

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) {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This catch (e) {} block completely swallows any errors that might occur when communicating with Sentry. This pattern is repeated on lines 245 and 432. While it's important not to let monitoring failures crash the application, silent failures can make it very difficult to debug issues with your Sentry integration itself. It would be better to at least log these errors to the console in all these locations.

Suggested change
} catch (e) {}
} catch (e) { console.error('[Sentry] Error during Sentry operation:', e); }

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
Expand All @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down
15 changes: 15 additions & 0 deletions exora/lib/llm-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 }],
Expand All @@ -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 }],
Expand All @@ -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',
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This catch (e) {} block completely swallows any errors that might occur when communicating with Sentry. While it's important not to let monitoring failures crash the application, silent failures can make it very difficult to debug issues with your Sentry integration itself. It would be better to at least log these errors to the console.

Suggested change
} catch (e) {
// swallow any Sentry failures
}
} catch (e) {
// swallow any Sentry failures but log them for debugging
console.error('[Sentry] Failed to capture LLM exception:', e);
}

console.error(`Error with ${provider}:`, error);
throw new Error(`Failed to generate text with ${provider}: ${error.message}`);
}
Expand Down
44 changes: 44 additions & 0 deletions exora/lib/sentry.ts
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)
}
}
Loading