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
|
### 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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue