Add import telemetry logging for success and failure reasons

This commit is contained in:
Paul Huliganga 2026-03-25 00:07:38 -04:00
parent 1ca21889ca
commit 9f49223df3
3 changed files with 78 additions and 2 deletions

View File

@ -39,7 +39,7 @@ MVP is functionally complete (core app + docs + tests).
### Phase 3: Fallback Parsing + Hardening ### Phase 3: Fallback Parsing + Hardening
- [x] Add heuristic fallback parser when Schema.org missing - [x] Add heuristic fallback parser when Schema.org missing
- [x] Add timeout/retry + user-friendly import failure messages - [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) ### Phase 4: Browser Extension (after URL import stable)
- [ ] Scaffold browser extension project (Manifest v3) - [ ] Scaffold browser extension project (Manifest v3)

View File

@ -1,4 +1,18 @@
import { Router } from 'express'; 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 { z } from 'zod';
import { UrlImportError, UrlImportService } from '../services/UrlImportService.js'; import { UrlImportError, UrlImportService } from '../services/UrlImportService.js';
import { SchemaOrgRecipeParserService } from '../services/SchemaOrgRecipeParserService.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) * Fetch an external recipe page and return imported, normalized Recipe (if found)
*/ */
router.post('/url', async (req, res) => { router.post('/url', async (req, res) => {
const startedAt = Date.now();
let requestUrl = 'unknown';
try { try {
const { url } = importUrlSchema.parse(req.body); const { url } = importUrlSchema.parse(req.body);
requestUrl = url;
const result = await urlImportService.fetchFromUrl(url); const result = await urlImportService.fetchFromUrl(url);
// Try to parse and normalize Recipe from JSON-LD blocks // 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 // 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); 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({ res.status(200).json({
success: true, success: true,
data: { ...result, draft_recipe: draft }, data: { ...result, draft_recipe: draft },
@ -52,6 +82,14 @@ export function createImportRoutes(): Router {
}); });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { 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({ res.status(400).json({
success: false, success: false,
data: null, data: null,
@ -61,6 +99,14 @@ export function createImportRoutes(): Router {
} }
if (error instanceof UrlImportError) { 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({ res.status(mapImportErrorToStatus(error)).json({
success: false, success: false,
data: null, data: null,
@ -70,6 +116,14 @@ export function createImportRoutes(): Router {
} }
if (error instanceof Error) { if (error instanceof Error) {
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: 'UNHANDLED_ERROR',
failureReason: error.message,
});
res.status(500).json({ res.status(500).json({
success: false, success: false,
data: null, data: null,
@ -78,6 +132,14 @@ export function createImportRoutes(): Router {
return; return;
} }
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: 'UNKNOWN_ERROR',
failureReason: 'Internal server error',
});
res.status(500).json({ res.status(500).json({
success: false, success: false,
data: null, data: null,

View File

@ -5,8 +5,10 @@ import { createImportRoutes } from '../routes/import.js';
describe('Import API', () => { describe('Import API', () => {
let app: express.Application; let app: express.Application;
let infoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
app = express(); app = express();
app.use(express.json()); app.use(express.json());
app.use('/api/import', createImportRoutes()); app.use('/api/import', createImportRoutes());
@ -58,6 +60,14 @@ describe('Import API', () => {
ingredients: ['Flour', 'Eggs'], ingredients: ['Flour', 'Eggs'],
instructions: ['Mix', 'Cook'] 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 () => { 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.success).toBe(false);
expect(response.body.error).toContain('timed out'); expect(response.body.error).toContain('timed out');
expect(infoSpy).toHaveBeenCalledWith(
'[import.telemetry]',
expect.stringContaining('"failureCode":"IMPORT_TIMEOUT"')
);
}); });