chore: complete v1.0 Analytics & Monitoring milestone

Archive milestone artifacts (roadmap, requirements, audit, phase directories)
to .planning/milestones/. Evolve PROJECT.md with validated requirements and
decision outcomes. Create MILESTONES.md and RETROSPECTIVE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
admin
2026-02-25 10:34:18 -05:00
parent 8bad951d63
commit 38a0f0619d
39 changed files with 299 additions and 186 deletions

View File

@@ -0,0 +1,550 @@
# Phase 3: API Layer - Research
**Researched:** 2026-02-24
**Domain:** Express.js admin route construction, Firebase Auth middleware, Supabase analytics queries
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Response shape & contracts**
- Analytics endpoint accepts a configurable time range via query param (e.g., `?range=24h`, `?range=7d`) with a sensible default
- Field naming convention: match whatever the existing codebase already uses (camelCase or snake_case) — stay consistent
**Auth & error behavior**
- Non-admin users receive 404 on admin endpoints — do not reveal that admin routes exist
- Unauthenticated requests: Claude decides whether to return 401 or same 404 based on existing auth middleware patterns
**Analytics instrumentation**
- Best-effort with logging: emit events asynchronously, log failures, but never let instrumentation errors propagate to processing
- Key milestones only — upload started, processing complete, processing failed (not every pipeline stage)
- Include duration/timing data per event — enables avg processing time metric in the analytics endpoint
**Endpoint conventions**
- Route prefix: match existing Express app patterns
- Acknowledge semantics: Claude decides (one-way, toggle, or with note — whatever fits best)
### Claude's Discretion
- Envelope pattern vs direct data for API responses
- Health endpoint detail level (flat status vs nested with last-check times)
- Admin role mechanism (Firebase custom claims vs Supabase role check vs other)
- Unauthenticated request handling (401 vs 404)
- Alert pagination strategy
- Alert filtering support
- Rate limiting on admin endpoints
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| INFR-02 | Admin API routes protected by Firebase Auth with admin email check | Firebase Auth `verifyFirebaseToken` middleware exists; need `requireAdmin` layer that checks `req.user.email` against `process.env.EMAIL_WEEKLY_RECIPIENT` (already configured for alerts) or a dedicated `ADMIN_EMAIL` env var |
| HLTH-01 | Admin can view live health status (healthy/degraded/down) for Document AI, Claude/OpenAI, Supabase, and Firebase Auth | `HealthCheckModel.findLatestByService()` already exists; need a query across all four service names or a loop; service names must match what `healthProbeService` writes |
| ANLY-02 | Admin can view processing summary: upload counts, success/failure rates, avg processing time | `document_processing_events` table exists with `event_type`, `duration_ms`, `created_at`; need a Supabase aggregation query grouped by `event_type` over a time window; `recordProcessingEvent()` must be called from `jobProcessorService.processJob()` (not yet called there) |
</phase_requirements>
---
## Summary
Phase 3 is entirely additive — it exposes data from Phase 1 and Phase 2 via admin-protected HTTP endpoints, and instruments the existing `jobProcessorService.processJob()` method with fire-and-forget analytics calls. No database schema changes are needed; all tables and models exist.
The three technical sub-problems are: (1) a two-layer auth middleware — Firebase token verification (existing `verifyFirebaseToken`) plus an admin email check (new, 5-10 lines); (2) three new route handlers reading from `HealthCheckModel`, `AlertEventModel`, and a new `getAnalyticsSummary()` function in `analyticsService`; and (3) inserting `recordProcessingEvent()` calls at three points inside `processJob()` without altering success/failure semantics.
The codebase is well-factored and consistent: route files live in `backend/src/routes/`, middleware in `backend/src/middleware/`, service functions in `backend/src/services/`. The existing `verifyFirebaseToken` middleware plus a new `requireAdminEmail` middleware compose cleanly onto the new `/admin` router. The existing `{ success: true, data: ..., correlationId: ... }` envelope is the established pattern and should be followed.
**Primary recommendation:** Add `adminRoutes.ts` to the existing routes directory, mount it at `/admin` in `index.ts`, compose `verifyFirebaseToken` + `requireAdminEmail` as router-level middleware, and wire three handlers to existing model/service methods. Instrument `processJob()` at job-start, completion, and failure using the existing `recordProcessingEvent()` signature.
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| express | already in use | Router, Request/Response types | Project standard |
| firebase-admin | already in use | Token verification (`verifyIdToken`) | Existing auth layer |
| @supabase/supabase-js | already in use | Database reads via `getSupabaseServiceClient()` | Project data layer |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| (none new) | — | All needed libraries already present | No new npm installs required |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Email-based admin check | Firebase custom claims | Custom claims require Firebase Admin SDK `setCustomUserClaims()` call — more setup; email check works with zero additional config since `EMAIL_WEEKLY_RECIPIENT` is already defined |
| Email-based admin check | Supabase role column | Cross-system lookup adds latency and a new dependency; email check is synchronous against the already-decoded token |
**Installation:** No new packages needed.
---
## Architecture Patterns
### Recommended Project Structure
```
backend/src/
├── routes/
│ ├── admin.ts # NEW — /admin router with health, analytics, alerts endpoints
│ ├── documents.ts # existing
│ ├── monitoring.ts # existing
│ └── ...
├── middleware/
│ ├── firebaseAuth.ts # existing — verifyFirebaseToken
│ ├── requireAdmin.ts # NEW — requireAdminEmail middleware (10-15 lines)
│ └── ...
├── services/
│ ├── analyticsService.ts # extend — add getAnalyticsSummary() query function
│ ├── jobProcessorService.ts # modify — add recordProcessingEvent() calls
│ └── ...
└── index.ts # modify — mount /admin routes
```
### Pattern 1: Two-Layer Admin Auth Middleware
**What:** `verifyFirebaseToken` handles token signature + expiry; `requireAdminEmail` checks that `req.user.email` equals the configured admin email. Admin routes apply both in sequence.
**When to use:** All `/admin/*` routes.
**Example:**
```typescript
// backend/src/middleware/requireAdmin.ts
import { Response, NextFunction } from 'express';
import { FirebaseAuthenticatedRequest } from './firebaseAuth';
import { logger } from '../utils/logger';
const ADMIN_EMAIL = process.env['ADMIN_EMAIL'] ?? process.env['EMAIL_WEEKLY_RECIPIENT'];
export function requireAdminEmail(
req: FirebaseAuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const userEmail = req.user?.email;
if (!userEmail || userEmail !== ADMIN_EMAIL) {
// 404 — do not reveal admin routes exist (per locked decision)
logger.warn('requireAdminEmail: access denied', {
uid: req.user?.uid ?? 'unauthenticated',
email: userEmail ?? 'none',
path: req.path,
});
res.status(404).json({ error: 'Not found' });
return;
}
next();
}
```
**Unauthenticated handling:** `verifyFirebaseToken` already returns 401 for missing/invalid tokens. Since it runs first, unauthenticated requests never reach `requireAdminEmail`. The 404 behavior (hiding admin routes) only applies to authenticated non-admin users — this is consistent with the existing middleware chain. No change needed to `verifyFirebaseToken`.
### Pattern 2: Admin Router Construction
**What:** A dedicated Express Router with both middleware applied at router level, then individual route handlers.
**When to use:** All admin endpoints.
**Example:**
```typescript
// backend/src/routes/admin.ts
import { Router, Request, Response } from 'express';
import { verifyFirebaseToken } from '../middleware/firebaseAuth';
import { requireAdminEmail } from '../middleware/requireAdmin';
import { addCorrelationId } from '../middleware/validation';
import { HealthCheckModel } from '../models/HealthCheckModel';
import { AlertEventModel } from '../models/AlertEventModel';
import { getAnalyticsSummary } from '../services/analyticsService';
import { logger } from '../utils/logger';
const router = Router();
// Auth chain: verify Firebase token, then assert admin email
router.use(verifyFirebaseToken);
router.use(requireAdminEmail);
router.use(addCorrelationId);
const SERVICE_NAMES = ['document_ai', 'llm', 'supabase', 'firebase_auth'] as const;
router.get('/health', async (req: Request, res: Response): Promise<void> => {
try {
const results = await Promise.all(
SERVICE_NAMES.map(name => HealthCheckModel.findLatestByService(name))
);
const health = SERVICE_NAMES.map((name, i) => ({
service: name,
status: results[i]?.status ?? 'unknown',
checkedAt: results[i]?.checked_at ?? null,
latencyMs: results[i]?.latency_ms ?? null,
errorMessage: results[i]?.error_message ?? null,
}));
res.json({ success: true, data: health, correlationId: req.correlationId });
} catch (error) {
logger.error('GET /admin/health failed', { error, correlationId: req.correlationId });
res.status(500).json({ success: false, error: 'Health query failed', correlationId: req.correlationId });
}
});
```
### Pattern 3: Analytics Summary Query
**What:** A new `getAnalyticsSummary(range: string)` function in `analyticsService.ts` that queries `document_processing_events` aggregated over a time window. Supabase JS client does not support `COUNT`/`AVG` aggregations directly — use the Postgres pool (`getPostgresPool().query()`) for aggregate SQL, consistent with how `runRetentionCleanup` and the scheduled function's health check already use the pool.
**When to use:** `GET /admin/analytics?range=24h`
**Range parsing:** `24h``24 hours`, `7d``7 days`. Default: `24h`.
**Example:**
```typescript
// backend/src/services/analyticsService.ts (addition)
import { getPostgresPool } from '../config/supabase';
export interface AnalyticsSummary {
range: string;
totalUploads: number;
succeeded: number;
failed: number;
successRate: number;
avgProcessingMs: number | null;
generatedAt: string;
}
export async function getAnalyticsSummary(range: string): Promise<AnalyticsSummary> {
const interval = parseRange(range); // '24h' -> '24 hours', '7d' -> '7 days'
const pool = getPostgresPool();
const { rows } = await pool.query<{
total_uploads: string;
succeeded: string;
failed: string;
avg_processing_ms: string | null;
}>(`
SELECT
COUNT(*) FILTER (WHERE event_type = 'upload_started') AS total_uploads,
COUNT(*) FILTER (WHERE event_type = 'completed') AS succeeded,
COUNT(*) FILTER (WHERE event_type = 'failed') AS failed,
AVG(duration_ms) FILTER (WHERE event_type = 'completed') AS avg_processing_ms
FROM document_processing_events
WHERE created_at >= NOW() - INTERVAL $1
`, [interval]);
const row = rows[0]!;
const total = parseInt(row.total_uploads, 10);
const succeeded = parseInt(row.succeeded, 10);
const failed = parseInt(row.failed, 10);
return {
range,
totalUploads: total,
succeeded,
failed,
successRate: total > 0 ? succeeded / total : 0,
avgProcessingMs: row.avg_processing_ms ? parseFloat(row.avg_processing_ms) : null,
generatedAt: new Date().toISOString(),
};
}
function parseRange(range: string): string {
if (/^\d+h$/.test(range)) return range.replace('h', ' hours');
if (/^\d+d$/.test(range)) return range.replace('d', ' days');
return '24 hours'; // fallback
}
```
### Pattern 4: Analytics Instrumentation in jobProcessorService
**What:** Three `recordProcessingEvent()` calls in `processJob()` at existing lifecycle points. The function signature already matches — `document_id`, `user_id`, `event_type`, optional `duration_ms` and `error_message`. The return type is `void` (not `Promise<void>`) so no `await` is possible.
**Key instrumentation points:**
1. After `ProcessingJobModel.markAsProcessing(jobId)` — emit `upload_started` (no duration)
2. After `ProcessingJobModel.markAsCompleted(...)` — emit `completed` with `duration_ms = Date.now() - startTime`
3. In the catch block before `ProcessingJobModel.markAsFailed(...)` — emit `failed` with `duration_ms` and `error_message`
**Example:**
```typescript
// In processJob(), after markAsProcessing:
recordProcessingEvent({
document_id: job.document_id,
user_id: job.user_id,
event_type: 'upload_started',
});
// After markAsCompleted:
recordProcessingEvent({
document_id: job.document_id,
user_id: job.user_id,
event_type: 'completed',
duration_ms: Date.now() - startTime,
});
// In catch, before markAsFailed:
recordProcessingEvent({
document_id: job.document_id,
user_id: job.user_id ?? '',
event_type: 'failed',
duration_ms: Date.now() - startTime,
error_message: errorMessage,
});
```
**Constraint:** `job` may be null in the catch block if `findById` failed. Guard with `job?.document_id` or skip instrumentation when `job` is null (it's already handled by the early return in that case).
### Pattern 5: Alert Acknowledge Semantics
**Decision:** One-way acknowledge (active → acknowledged). `AlertEventModel.acknowledge(id)` already implements exactly this. No toggle, no note field. The endpoint returns the updated alert object.
```typescript
router.post('/alerts/:id/acknowledge', async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
try {
const updated = await AlertEventModel.acknowledge(id);
res.json({ success: true, data: updated, correlationId: req.correlationId });
} catch (error) {
// AlertEventModel.acknowledge throws a specific error when id not found
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('not found')) {
res.status(404).json({ success: false, error: 'Alert not found', correlationId: req.correlationId });
return;
}
logger.error('POST /admin/alerts/:id/acknowledge failed', { id, error: msg });
res.status(500).json({ success: false, error: 'Acknowledge failed', correlationId: req.correlationId });
}
});
```
### Anti-Patterns to Avoid
- **Awaiting `recordProcessingEvent()`:** Its return type is `void`, not `Promise<void>`. Calling `await recordProcessingEvent(...)` is a TypeScript error and would break the fire-and-forget guarantee.
- **Supabase JS `.select()` for aggregates:** Supabase JS client does not support SQL aggregate functions (`COUNT`, `AVG`). Use `getPostgresPool().query()` for analytics queries.
- **Caching admin email at module level:** Firebase Secrets are not available at module load time. Read `process.env['ADMIN_EMAIL']` inside the middleware function, not at the top of the file — or use lazy evaluation. The alertService precedent (creating transporter inside function scope) demonstrates this pattern.
- **Revealing admin routes to non-admin users:** Never return 403 on admin routes — always return 404 to unauthenticated/non-admin callers (per locked decision). Since `verifyFirebaseToken` runs first and returns 401 for unauthenticated requests, unauthenticated callers get 401 (expected, token verification precedes admin check). Authenticated non-admin callers get 404.
- **Mutating existing `processJob()` logic:** Analytics calls go around existing `markAsProcessing`, `markAsCompleted`, `markAsFailed` calls — never replacing or wrapping them.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Token verification | Custom JWT validation | `verifyFirebaseToken` (already exists) | Handles expiry, revocation, recovery from session |
| Health data retrieval | Raw SQL or in-memory aggregation | `HealthCheckModel.findLatestByService()` (already exists) | Validated input, proper error handling, same pattern as Phase 2 |
| Alert CRUD | New Supabase queries | `AlertEventModel.findActive()`, `AlertEventModel.acknowledge()` (already exist) | Consistent error handling, deduplication-aware |
| Correlation IDs | Custom header logic | `addCorrelationId` middleware (already exists) | Applied at router level like other route files |
**Key insight:** Phase 3 is primarily composition, not construction. Nearly all data access is through existing models. The only new code is the admin router, the admin email middleware, the `getAnalyticsSummary()` function, and three `recordProcessingEvent()` call sites.
---
## Common Pitfalls
### Pitfall 1: Admin Email Source
**What goes wrong:** `ADMIN_EMAIL` env var is not defined; admin check silently passes (if the check is `email === undefined`) or silently blocks all admin access.
**Why it happens:** The codebase uses `EMAIL_WEEKLY_RECIPIENT` for the alert recipient — there is no `ADMIN_EMAIL` variable yet. If `ADMIN_EMAIL` is not set and the check falls back to `undefined`, `email !== undefined` would always be true (blocking all) or the inverse.
**How to avoid:** Read `ADMIN_EMAIL ?? EMAIL_WEEKLY_RECIPIENT` as fallback. Log a `logger.warn` at startup/first call if neither is defined. If neither is set, fail closed (deny all admin access) with a logged warning.
**Warning signs:** Admin endpoints return 404 even when authenticated with the correct email.
### Pitfall 2: Service Name Mismatch on Health Endpoint
**What goes wrong:** `GET /admin/health` returns `status: null` / `checkedAt: null` for all services because the service names in the query don't match what `healthProbeService` writes.
**Why it happens:** `HealthCheckModel.findLatestByService(serviceName)` does an exact string match. If the route handler uses `'document-ai'` but the probe writes `'document_ai'`, the join finds nothing.
**How to avoid:** Read `healthProbeService.ts` to confirm the exact service name strings used in `HealthCheckResult` / passed to `HealthCheckModel.create()`. Use those exact strings in the admin route.
**Warning signs:** Response data has `status: 'unknown'` for all services.
### Pitfall 3: `job.user_id` Type in Analytics Instrumentation
**What goes wrong:** TypeScript error or runtime `undefined` when emitting `recordProcessingEvent` in the catch block.
**Why it happens:** `job` can be `null` if `ProcessingJobModel.findById()` threw before `job` was assigned. The catch block handles all errors, including the pre-assignment path.
**How to avoid:** Guard instrumentation with `if (job)` in the catch block. `ProcessingEventData.user_id` is typed as `string`, so pass `job.user_id` only when `job` is non-null.
**Warning signs:** TypeScript compile error on `job.user_id` in catch block.
### Pitfall 4: `getPostgresPool()` vs `getSupabaseServiceClient()` for Aggregates
**What goes wrong:** Using `getSupabaseServiceClient().from('document_processing_events').select(...)` for the analytics summary and getting back raw rows instead of aggregated counts.
**Why it happens:** Supabase JS PostgREST client does not support SQL aggregate functions in the query builder.
**How to avoid:** Use `getPostgresPool().query(sql, params)` for the analytics aggregate query, consistent with how `processDocumentJobs` scheduled function performs its DB health check and how `cleanupOldData` runs bulk deletes.
**Warning signs:** `getAnalyticsSummary` returns row-level data instead of aggregated counts.
### Pitfall 5: Route Registration Order in index.ts
**What goes wrong:** Admin routes conflict with or shadow existing routes.
**Why it happens:** Express matches routes in registration order. Registering `/admin` before `/documents` is fine as long as there are no overlapping paths.
**How to avoid:** Add `app.use('/admin', adminRoutes)` alongside the existing route registrations. The `/admin` prefix is unique — no conflicts expected.
**Warning signs:** Existing document/monitoring routes stop working after adding admin routes.
---
## Code Examples
Verified patterns from the existing codebase:
### Existing Route File Pattern (from routes/monitoring.ts)
```typescript
// Source: backend/src/routes/monitoring.ts
import { Router, Request, Response } from 'express';
import { addCorrelationId } from '../middleware/validation';
import { logger } from '../utils/logger';
const router = Router();
router.use(addCorrelationId);
router.get('/some-endpoint', async (req: Request, res: Response): Promise<void> => {
try {
// ... data access
res.json({
success: true,
data: someData,
correlationId: req.correlationId || undefined,
});
} catch (error) {
logger.error('Failed', {
category: 'monitoring',
operation: 'some_op',
error: error instanceof Error ? error.message : 'Unknown error',
correlationId: req.correlationId || undefined,
});
res.status(500).json({
success: false,
error: 'Failed to retrieve data',
correlationId: req.correlationId || undefined,
});
}
});
export default router;
```
### Existing Middleware Pattern (from middleware/firebaseAuth.ts)
```typescript
// Source: backend/src/middleware/firebaseAuth.ts
export interface FirebaseAuthenticatedRequest extends Request {
user?: admin.auth.DecodedIdToken;
}
export const verifyFirebaseToken = async (
req: FirebaseAuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
// ... verifies token, sets req.user, calls next() or returns 401
};
```
### Existing Model Pattern (from models/HealthCheckModel.ts)
```typescript
// Source: backend/src/models/HealthCheckModel.ts
static async findLatestByService(serviceName: string): Promise<ServiceHealthCheck | null> {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('service_health_checks')
.select('*')
.eq('service_name', serviceName)
.order('checked_at', { ascending: false })
.limit(1)
.single();
if (error?.code === 'PGRST116') return null;
// ...
}
```
### Existing Analytics Record Pattern (from services/analyticsService.ts)
```typescript
// Source: backend/src/services/analyticsService.ts
// Return type is void (NOT Promise<void>) — prevents accidental await on critical path
export function recordProcessingEvent(data: ProcessingEventData): void {
const supabase = getSupabaseServiceClient();
void supabase
.from('document_processing_events')
.insert({ ... })
.then(({ error }) => {
if (error) logger.error('analyticsService: failed to insert processing event', { ... });
});
}
```
### Route Registration Pattern (from index.ts)
```typescript
// Source: backend/src/index.ts
app.use('/documents', documentRoutes);
app.use('/vector', vectorRoutes);
app.use('/monitoring', monitoringRoutes);
app.use('/api/audit', auditRoutes);
// New:
app.use('/admin', adminRoutes);
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Legacy auth middleware (auth.ts) | Firebase Auth (firebaseAuth.ts) | Pre-Phase 3 | `auth.ts` is fully deprecated and returns 501 — do not use it |
| In-memory monitoring (uploadMonitoringService) | Supabase-persisted health checks and analytics | Phase 1-2 | Admin endpoints must read from Supabase, not in-memory state |
| Direct `console.log` | Winston logger (`logger` from `utils/logger.ts`) | Pre-Phase 3 | Always use `logger.info/warn/error/debug` |
**Deprecated/outdated:**
- `backend/src/middleware/auth.ts`: All exports (`authenticateToken`, `requireAdmin`, `requireRole`) return 501. Do not import. Use `firebaseAuth.ts`.
- `uploadMonitoringService`: In-memory service. Not suitable for admin health dashboard — data does not survive cold starts.
---
## Open Questions
1. **Exact service name strings written by healthProbeService**
- What we know: The service names come from whatever `healthProbeService.ts` passes to `HealthCheckModel.create({ service_name: ... })`
- What's unclear: The exact strings — likely `'document_ai'`, `'llm'`, `'supabase'`, `'firebase_auth'` but must be verified before writing the health handler
- Recommendation: Read `healthProbeService.ts` during plan/implementation to confirm exact strings before writing `SERVICE_NAMES` constant in the admin route
2. **`job.user_id` field type confirmation**
- What we know: `ProcessingEventData.user_id` is typed as `string`; `ProcessingJob` model has `user_id` field
- What's unclear: Whether `ProcessingJob.user_id` can ever be `undefined`/nullable in practice
- Recommendation: Check `ProcessingJobModel` type definition during implementation; add defensive `?? ''` if nullable
3. **Alert pagination for GET /admin/alerts**
- What we know: `AlertEventModel.findActive()` returns all active alerts without limit; for a single-admin system this is unlikely to be an issue
- What's unclear: Whether a limit/offset param is needed
- Recommendation: Claude's discretion — default to returning all active alerts (no pagination) given single-admin use case; add `?limit=N` support as optional param using `.limit()` on the Supabase query
---
## Sources
### Primary (HIGH confidence)
- Codebase: `backend/src/middleware/firebaseAuth.ts` — verifyFirebaseToken implementation, FirebaseAuthenticatedRequest interface, 401 error responses
- Codebase: `backend/src/models/HealthCheckModel.ts` — findLatestByService, findAll, deleteOlderThan patterns
- Codebase: `backend/src/models/AlertEventModel.ts` — findActive, acknowledge, resolve, findRecentByService patterns
- Codebase: `backend/src/services/analyticsService.ts` — recordProcessingEvent (void return), deleteProcessingEventsOlderThan (pool.query pattern)
- Codebase: `backend/src/services/jobProcessorService.ts` — processJob lifecycle: startTime capture, markAsProcessing, markAsCompleted, markAsFailed, catch block structure
- Codebase: `backend/src/routes/monitoring.ts` — route file pattern, envelope shape `{ success, data, correlationId }`
- Codebase: `backend/src/index.ts` — route registration, Express app structure, existing `/health` endpoint shape
- Codebase: `backend/src/models/migrations/012_create_monitoring_tables.sql` — exact column names for service_health_checks, alert_events
- Codebase: `backend/src/models/migrations/013_create_processing_events_table.sql` — exact column names for document_processing_events
### Secondary (MEDIUM confidence)
- Codebase: `backend/src/services/alertService.ts` — pattern for reading `process.env['EMAIL_WEEKLY_RECIPIENT']` inside function (not at module level) to avoid Firebase Secrets timing issue
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all libraries already in use; no new dependencies
- Architecture: HIGH — patterns derived from existing codebase, not assumptions
- Pitfalls: HIGH — three of five pitfalls are directly observable from reading the existing code
- Open questions: LOW confidence only on exact service name strings (requires reading one more file)
**Research date:** 2026-02-24
**Valid until:** 2026-03-24 (stable codebase; valid until significant refactoring)