Skip to content

Commit 3f33be6

Browse files
Merge issue-209-telemetry-r2-clean: feat: emit correlated API request outcomes (#209)
2 parents 2f28fad + 5422f23 commit 3f33be6

3 files changed

Lines changed: 1301 additions & 28 deletions

File tree

backend/src/apiRequestTelemetry.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { randomUUID } from 'node:crypto';
2+
3+
import { DOCS_ROUTE_PREFIXES } from './docs/contentApi';
4+
5+
export const API_TELEMETRY_NAMESPACE = 'DataOps/PortalAPI';
6+
7+
export type ApiRouteFamily =
8+
| 'health_auth_team'
9+
| 'tasks'
10+
| 'cards'
11+
| 'templates'
12+
| 'recurring'
13+
| 'files'
14+
| 'artifacts'
15+
| 'assistant_jobs_social_drafts'
16+
| 'intake'
17+
| 'users_tokens'
18+
| 'notifications'
19+
| 'calendar_newsletter'
20+
| 'bookkeeping'
21+
| 'sponsor_crm'
22+
| 'mailing_exports'
23+
| 'conversational_telegram'
24+
| 'email_documents'
25+
| 'docs_content_search'
26+
| 'static_frontend'
27+
| 'other';
28+
29+
export type ApiRequestMethod =
30+
| 'DELETE'
31+
| 'GET'
32+
| 'HEAD'
33+
| 'OPTIONS'
34+
| 'OTHER'
35+
| 'PATCH'
36+
| 'POST'
37+
| 'PUT';
38+
39+
export type ApiStatusClass = '2xx' | '3xx' | '4xx' | '5xx';
40+
export type ApiErrorClass = 'route_handled' | 'unhandled_exception';
41+
export type ApiRequestMetricEmitter = (input: ApiRequestMetricInput) => void;
42+
43+
export interface ApiRequestMetricInput {
44+
routeFamily: ApiRouteFamily;
45+
method: ApiRequestMethod;
46+
statusCode: number;
47+
durationMs: number;
48+
requestId: string;
49+
coldStart: boolean;
50+
timestamp?: number;
51+
errorClass?: ApiErrorClass;
52+
}
53+
54+
const REQUEST_METHODS = new Set([
55+
'DELETE',
56+
'GET',
57+
'HEAD',
58+
'OPTIONS',
59+
'PATCH',
60+
'POST',
61+
'PUT',
62+
]);
63+
64+
const ROUTE_FAMILIES: ReadonlyArray<readonly [ApiRouteFamily, string]> = [
65+
['email_documents', '/api/v1/intake/email-documents'],
66+
['conversational_telegram', '/api/webhook/telegram'],
67+
['email_documents', '/api/webhook/email'],
68+
['health_auth_team', '/api/health'],
69+
['health_auth_team', '/api/auth'],
70+
['health_auth_team', '/api/me'],
71+
['health_auth_team', '/api/team-members'],
72+
['tasks', '/api/tasks'],
73+
['cards', '/api/cards'],
74+
['templates', '/api/templates'],
75+
['recurring', '/api/recurring'],
76+
['files', '/api/files'],
77+
['artifacts', '/api/artifacts'],
78+
['assistant_jobs_social_drafts', '/api/assistant-jobs'],
79+
['assistant_jobs_social_drafts', '/api/assistant-social-drafts'],
80+
['intake', '/api/intake'],
81+
['users_tokens', '/api/users'],
82+
['users_tokens', '/api/tokens'],
83+
['notifications', '/api/notifications'],
84+
['calendar_newsletter', '/api/calendar-items'],
85+
['calendar_newsletter', '/api/newsletter-slots'],
86+
['bookkeeping', '/api/bookkeeping'],
87+
['recurring', '/api/cron'],
88+
['sponsor_crm', '/api/sponsor-crm'],
89+
['mailing_exports', '/api/mailing-exports'],
90+
['conversational_telegram', '/api/conversational'],
91+
];
92+
93+
const ROUTE_FAMILY_VALUES = new Set<string>(
94+
ROUTE_FAMILIES.map(([family]) => family).concat('other')
95+
);
96+
97+
function matchesPrefix(path: string, prefix: string): boolean {
98+
return path === prefix || path.startsWith(`${prefix}/`);
99+
}
100+
101+
function canonicalClassificationPath(path: unknown): string {
102+
const value = typeof path === 'string' && path.length > 0 ? path : '/';
103+
if (value === '/work/health') return '/api/health';
104+
if (value === '/work/api') return '/api';
105+
if (value.startsWith('/work/api/')) return value.slice('/work'.length);
106+
return value;
107+
}
108+
109+
export function classifyApiRouteFamily(path: unknown): ApiRouteFamily {
110+
const normalizedPath = canonicalClassificationPath(path);
111+
const isApiPath = normalizedPath === '/api'
112+
|| normalizedPath.startsWith('/api/');
113+
114+
for (const [family, prefix] of ROUTE_FAMILIES) {
115+
if (matchesPrefix(normalizedPath, prefix)) return family;
116+
}
117+
118+
if (
119+
DOCS_ROUTE_PREFIXES.some((prefix) => matchesPrefix(normalizedPath, prefix))
120+
|| matchesPrefix(normalizedPath, '/content')
121+
) {
122+
return 'docs_content_search';
123+
}
124+
125+
return isApiPath ? 'other' : 'static_frontend';
126+
}
127+
128+
export function apiRequestMethod(method: unknown): ApiRequestMethod {
129+
const normalized = String(method ?? 'GET').trim().toUpperCase();
130+
return REQUEST_METHODS.has(normalized) ? normalized as ApiRequestMethod : 'OTHER';
131+
}
132+
133+
export function apiStatusClass(statusCode: number): ApiStatusClass {
134+
if (statusCode >= 300 && statusCode < 400) return '3xx';
135+
if (statusCode >= 400 && statusCode < 500) return '4xx';
136+
if (statusCode >= 500 && statusCode < 600) return '5xx';
137+
return '2xx';
138+
}
139+
140+
function safeHeaderId(candidate: string): string | null {
141+
return candidate.length > 0
142+
&& candidate.length <= 128
143+
&& /^[A-Za-z0-9._-]+$/.test(candidate)
144+
? candidate
145+
: null;
146+
}
147+
148+
/**
149+
* CloudWatch accepts each serialized object as one EMF record. Only fixed
150+
* dimensions and platform-provided correlation metadata are exposed here.
151+
*/
152+
export function emitApiRequestMetrics(input: ApiRequestMetricInput): void {
153+
try {
154+
const timestamp = input.timestamp ?? Date.now();
155+
const durationMs = Math.max(0, Number.isFinite(input.durationMs) ? input.durationMs : 0);
156+
const statusClass = apiStatusClass(input.statusCode);
157+
const failureValue = statusClass === '5xx' ? 1 : 0;
158+
const requestId = safeHeaderId(input.requestId);
159+
if (
160+
!Number.isSafeInteger(timestamp)
161+
|| requestId === null
162+
|| !ROUTE_FAMILY_VALUES.has(input.routeFamily)
163+
) return;
164+
165+
const record: Record<string, unknown> = {
166+
_aws: {
167+
Timestamp: timestamp,
168+
CloudWatchMetrics: [{
169+
Namespace: API_TELEMETRY_NAMESPACE,
170+
Dimensions: [['RouteFamily', 'Method', 'StatusClass']],
171+
Metrics: [
172+
{ Name: 'RequestCount', Unit: 'Count' },
173+
{ Name: 'RequestDurationMs', Unit: 'Milliseconds' },
174+
{ Name: 'HandledApiFailures', Unit: 'Count' },
175+
],
176+
}],
177+
},
178+
RouteFamily: input.routeFamily,
179+
Method: input.method,
180+
StatusClass: statusClass,
181+
RequestCount: 1,
182+
RequestDurationMs: durationMs,
183+
HandledApiFailures: failureValue,
184+
RequestId: requestId,
185+
ColdStart: Boolean(input.coldStart),
186+
};
187+
188+
if (failureValue === 1) record.ErrorClass = input.errorClass ?? 'route_handled';
189+
console.log(JSON.stringify(record));
190+
} catch {
191+
// Observability must never replace the HTTP business response.
192+
}
193+
}
194+
195+
export function safeApiRequestId(context: unknown): string {
196+
const candidate = (context as { awsRequestId?: unknown } | null | undefined)?.awsRequestId;
197+
if (typeof candidate !== 'string') return randomUUID();
198+
return safeHeaderId(candidate.trim()) ?? randomUUID();
199+
}

0 commit comments

Comments
 (0)