diff --git a/TODO.md b/TODO.md index 0289560..d81d3ee 100644 --- a/TODO.md +++ b/TODO.md @@ -39,7 +39,7 @@ MVP is functionally complete (core app + docs + tests). ### Phase 3: Fallback Parsing + Hardening - [x] Add heuristic fallback parser when Schema.org missing - [x] Add timeout/retry + user-friendly import failure messages -- [ ] Add logging/telemetry for import success/failure reasons +- [x] Add logging/telemetry for import success/failure reasons ### Phase 4: Browser Extension (after URL import stable) - [ ] Scaffold browser extension project (Manifest v3) diff --git a/src/backend/routes/import.ts b/src/backend/routes/import.ts index 98899e9..8efb83a 100644 --- a/src/backend/routes/import.ts +++ b/src/backend/routes/import.ts @@ -1,4 +1,18 @@ import { Router } from 'express'; + +type ImportTelemetryEvent = { + event: 'import_success' | 'import_failure'; + url: string; + parser?: 'schema_org' | 'heuristic' | 'none'; + jsonLdBlockCount?: number; + durationMs: number; + failureCode?: string; + failureReason?: string; +}; + +function logImportTelemetry(event: ImportTelemetryEvent): void { + console.info('[import.telemetry]', JSON.stringify(event)); +} import { z } from 'zod'; import { UrlImportError, UrlImportService } from '../services/UrlImportService.js'; import { SchemaOrgRecipeParserService } from '../services/SchemaOrgRecipeParserService.js'; @@ -29,8 +43,12 @@ export function createImportRoutes(): Router { * Fetch an external recipe page and return imported, normalized Recipe (if found) */ router.post('/url', async (req, res) => { + const startedAt = Date.now(); + let requestUrl = 'unknown'; + try { const { url } = importUrlSchema.parse(req.body); + requestUrl = url; const result = await urlImportService.fetchFromUrl(url); // Try to parse and normalize Recipe from JSON-LD blocks @@ -41,10 +59,22 @@ export function createImportRoutes(): Router { } // Fallback: heuristic HTML parser when Schema.org data is missing/invalid - if (!draft) { + let parserUsed: 'schema_org' | 'heuristic' | 'none' = 'none'; + if (draft) { + parserUsed = 'schema_org'; + } else { draft = heuristicParser.parseHtml(result.html, result.source_url); + parserUsed = draft ? 'heuristic' : 'none'; } + logImportTelemetry({ + event: 'import_success', + url: requestUrl, + parser: parserUsed, + jsonLdBlockCount: result.json_ld_blocks.length, + durationMs: Date.now() - startedAt, + }); + res.status(200).json({ success: true, data: { ...result, draft_recipe: draft }, @@ -52,6 +82,14 @@ export function createImportRoutes(): Router { }); } catch (error) { if (error instanceof z.ZodError) { + logImportTelemetry({ + event: 'import_failure', + url: requestUrl, + durationMs: Date.now() - startedAt, + failureCode: 'VALIDATION_ERROR', + failureReason: error.issues[0]?.message ?? 'Request validation failed', + }); + res.status(400).json({ success: false, data: null, @@ -61,6 +99,14 @@ export function createImportRoutes(): Router { } if (error instanceof UrlImportError) { + logImportTelemetry({ + event: 'import_failure', + url: requestUrl, + durationMs: Date.now() - startedAt, + failureCode: error.code, + failureReason: error.message, + }); + res.status(mapImportErrorToStatus(error)).json({ success: false, data: null, @@ -70,6 +116,14 @@ export function createImportRoutes(): Router { } if (error instanceof Error) { + logImportTelemetry({ + event: 'import_failure', + url: requestUrl, + durationMs: Date.now() - startedAt, + failureCode: 'UNHANDLED_ERROR', + failureReason: error.message, + }); + res.status(500).json({ success: false, data: null, @@ -78,6 +132,14 @@ export function createImportRoutes(): Router { return; } + logImportTelemetry({ + event: 'import_failure', + url: requestUrl, + durationMs: Date.now() - startedAt, + failureCode: 'UNKNOWN_ERROR', + failureReason: 'Internal server error', + }); + res.status(500).json({ success: false, data: null, diff --git a/src/backend/tests/import.test.ts b/src/backend/tests/import.test.ts index 8938be2..dc5dcff 100644 --- a/src/backend/tests/import.test.ts +++ b/src/backend/tests/import.test.ts @@ -5,8 +5,10 @@ import { createImportRoutes } from '../routes/import.js'; describe('Import API', () => { let app: express.Application; + let infoSpy: ReturnType; beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); app = express(); app.use(express.json()); app.use('/api/import', createImportRoutes()); @@ -58,6 +60,14 @@ describe('Import API', () => { ingredients: ['Flour', 'Eggs'], instructions: ['Mix', 'Cook'] }); + expect(infoSpy).toHaveBeenCalledWith( + '[import.telemetry]', + expect.stringContaining('"event":"import_success"') + ); + expect(infoSpy).toHaveBeenCalledWith( + '[import.telemetry]', + expect.stringContaining('"parser":"schema_org"') + ); }); it('should normalize whitespace and HowToStep instructions into draft format', async () => { @@ -235,6 +245,10 @@ describe('Import API', () => { expect(response.body.success).toBe(false); expect(response.body.error).toContain('timed out'); + expect(infoSpy).toHaveBeenCalledWith( + '[import.telemetry]', + expect.stringContaining('"failureCode":"IMPORT_TIMEOUT"') + ); });