Add import telemetry logging for success and failure reasons
This commit is contained in:
parent
1ca21889ca
commit
9f49223df3
2
TODO.md
2
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { createImportRoutes } from '../routes/import.js';
|
|||
|
||||
describe('Import API', () => {
|
||||
let app: express.Application;
|
||||
let infoSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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"')
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue