Eventos RUM / sesion
38
page views + clicks + exceptions
Trazas GenAI / usuario
12
invoke_agent ยท chat ยท tool spans
Core Web Vitals
LCP 1.4s
CLS 0.04 ยท INP 68ms ยท GOOD
Correlacion distribuida
100%
W3C traceparent โ Azure OpenAI
Arquitectura de telemetria
Browser React
NutriBot SPA
โ
RUM auto-track
App Insights SDK
appInsights.ts singleton
โ
HTTPS batch
Azure Monitor
West Europe
โ
traceparent
Azure OpenAI
gpt-4o-mini backend
W3C
traceparent propaga el operation_Id del browser al backend โ todos los spans del agente IA se correlacionan en un solo mapa de dependencias
Archivos generados
src/telemetry/appInsights.ts
singleton
// NutriBot โ Application Insights singleton // Inicializar UNA vez antes del router import { ApplicationInsights, DistributedTracingModes, type ITelemetryItem } from "@microsoft/applicationinsights-web"; import { ReactPlugin } from "@microsoft/applicationinsights-react-js"; import { ClickAnalyticsPlugin } from "@microsoft/applicationinsights-clickanalytics-js"; import { createBrowserHistory } from "history"; export const browserHistory = createBrowserHistory(); export const reactPlugin = new ReactPlugin(); const clickPlugin = new ClickAnalyticsPlugin(); export const appInsights = new ApplicationInsights({ config: { connectionString: import.meta.env.VITE_APPINSIGHTS_CONNECTION_STRING, extensions: [reactPlugin, clickPlugin], extensionConfig: { [reactPlugin.identifier]: { history: browserHistory }, [clickPlugin.identifier]: { autoCapture: true, dataTags: { useDefaultContentNameOrId: true, customDataPrefix: "data-ai-" } } }, // Correlacion distribuida W3C distributedTracingMode: DistributedTracingModes.AI_AND_W3C, enableCorsCorrelation: true, enableRequestHeaderTracking: true, enableResponseHeaderTracking: true, autoTrackPageVisitTime: true, disableFetchTracking: false, // GDPR: cookies desactivadas por defecto isCookieUseDisabled: true, excludeRequestFromAutoTrackingPatterns: [ /livemetrics\.azure\.com/i ] } }); // Enriquecimiento global de telemetria appInsights.addTelemetryInitializer( (item: ITelemetryItem) => { item.tags ??= {}; item.tags["ai.cloud.role"] = "nutribot-web"; item.tags["ai.cloud.roleInstance"] = window.location.hostname; item.data ??= {}; item.data["app.version"] = import.meta.env.VITE_APP_VERSION; // "2.4.1" item.data["app.build"] = import.meta.env.VITE_BUILD_SHA; // "a3f9c12" // Scrub tokens de query string (GDPR) if (item.baseData?.uri) { item.baseData.uri = item.baseData.uri .replace( /([?&](token|sig|key)=)[^&]+/gi, "$1REDACTED" ); } // Descartar healthcheck if (item.baseType === "PageviewData" && item.baseData?.uri?.endsWith("/healthz")) return false; } ); appInsights.loadAppInsights(); appInsights.trackPageView(); // inicial // Activar cookies cuando el usuario acepta el banner export function enableTelemetryCookies() { appInsights.getCookieMgr().setEnabled(true); appInsights.config.disableTelemetry = false; }
src/telemetry/useAIAgentTracing.ts
hook
// Hook React para trazas OTel GenAI del agente // de planificacion de dieta (NutriBot) import { SeverityLevel } from "@microsoft/applicationinsights-web"; import { appInsights } from "./appInsights"; type GenAiAttrs = Record< string, string | number | boolean | undefined >; function startSpan(name: string, attrs: GenAiAttrs) { const id = crypto.randomUUID(); const t0 = performance.now(); return { end( ok: boolean, extra: GenAiAttrs = {}, err?: Error ) { const ms = Math.round(performance.now() - t0); appInsights.trackDependencyData({ id, name, duration: ms, success: ok, responseCode: err ? 500 : 200, type: "GenAI", target: String( attrs["gen_ai.provider.name"] ?? "genai" ), properties: { ...attrs, ...extra } as Record<string, string> }); if (err) appInsights.trackException({ exception: err, severityLevel: SeverityLevel.Error, properties: { "gen_ai.operation.name": String(attrs["gen_ai.operation.name"]) } }); } }; } /** Invoca el agente de planificacion y emite * trazas: invoke_agent โ chat โ execute_tool */ export async function traceNutriBotAgent( prompt: string, tenantId: string ): Promise<string> { // 1. Span principal: invoke_agent const agent = startSpan( "invoke_agent NutriPlannerAgent", { "gen_ai.operation.name": "invoke_agent", "gen_ai.provider.name": "azure.ai.openai", "gen_ai.agent.name": "NutriPlannerAgent", "gen_ai.agent.id": "asst_nutri_v2_prod", "gen_ai.request.model": "gpt-4o-mini", "server.address": "nutribot.openai.azure.com", "tenant.id": tenantId }); try { // 2. Span chat const chat = startSpan( "chat gpt-4o-mini", { "gen_ai.operation.name": "chat", "gen_ai.provider.name": "azure.ai.openai", "gen_ai.request.model": "gpt-4o-mini" }); const res = await fetch( "/api/nutriplanner", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, tenantId }) } ); const data = await res.json(); chat.end(true, { "gen_ai.response.model": data.model, "gen_ai.response.id": data.id, "gen_ai.usage.input_tokens": data.usage.prompt_tokens, "gen_ai.usage.output_tokens": data.usage.completion_tokens, "gen_ai.output.type": "text" }); // 3. Span tool: busqueda de alimentos const tool = startSpan( "execute_tool searchFoodDB", { "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "searchFoodDB", "gen_ai.tool.type": "datastore", "gen_ai.tool.call.id": data.toolCallId }); const foodRes = await fetch( `/api/foods?q=${data.foodQuery}` ); tool.end(true); agent.end(true, { "gen_ai.usage.input_tokens": data.usage.prompt_tokens, "gen_ai.usage.output_tokens": data.usage.completion_tokens }); return data.plan; } catch (err) { agent.end(false, {}, err as Error); throw err; } }
src/telemetry/webVitals.ts
vitals
// Core Web Vitals โ Azure Monitor // Ejecutar una vez, tras loadAppInsights() import { onCLS, onLCP, onINP, type Metric } from "web-vitals"; import { appInsights } from "./appInsights"; function sendVital(m: Metric) { appInsights.trackMetric( { name: `web_vitals.${m.name.toLowerCase()}`, average: m.value }, { rating: m.rating, // "good"|"needs-improvement"|"poor" navigationType: m.navigationType, id: m.id, // NutriBot custom dimensions "page.route": window.location.pathname, "tenant.id": sessionStorage.getItem( "nb_tenant" ) ?? "unknown" } ); } export function registerWebVitals() { onCLS(sendVital); // Cumulative Layout Shift onLCP(sendVital); // Largest Contentful Paint onINP(sendVital); // Interaction to Next Paint } /* Umbral de referencia NutriBot (v2.4.1): LCP โค 2.5 s โ resultado actual: 1.4 s โ GOOD CLS โค 0.10 โ resultado actual: 0.04 โ GOOD INP โค 200 ms โ resultado actual: 68 ms โ GOOD */
src/App.tsx
entry point
// Punto de entrada โ NutriBot SPA // Inicializa telemetria antes del render import "./telemetry/appInsights"; // โ mรกs temprano posible import { registerWebVitals } from "./telemetry/webVitals"; import { traceNutriBotAgent } from "./telemetry/useAIAgentTracing"; import { appInsights, enableTelemetryCookies } from "./telemetry/appInsights"; import { Router, Route, Routes } from "react-router-dom"; registerWebVitals(); // โก Web Vitals export function App() { const { tenantId, userId } = useAuth(); // Identidad autenticada (sin PII โ usar IDs) React.useEffect(() => { if (userId) appInsights.setAuthenticatedUserContext( userId, tenantId, true ); return () => appInsights.clearAuthenticatedUserContext(); }, [userId]); async function handlePlanRequest( prompt: string ) { appInsights.trackEvent( { name: "PlanRequestStarted" }, { tenantId, promptLength: prompt.length } ); try { const plan = await traceNutriBotAgent(prompt, tenantId); appInsights.trackEvent( { name: "PlanRequestCompleted" }, { tenantId } ); return plan; } catch (err) { appInsights.trackException({ exception: err as Error, properties: { tenantId } }); } } return ( <Routes> <Route path="/" element={<Dashboard />} /> <Route path="/plan" element={ <PlannerPage onRequest={handlePlanRequest} /> } /> <Route path="/clinic/:id" element={<ClinicView />} /> </Routes> ); } // Boton CTA rastreado via Click Analytics // (data-ai-* attributes en JSX) function PlannerCTA() { return ( <button data-ai-name="GenerarPlanBtn" data-ai-behavior="generate_plan" data-ai-parentid="planner_section" onClick={() => handlePlanRequest("Plan semanal bajo en sodio") } > Generar plan nutricional โจ </button> ); } // Banner GDPR โ activa cookies al aceptar function CookieBanner() { return ( <button onClick={enableTelemetryCookies}> Aceptar cookies de analisis </button> ); }
KQL โ consulta GenAI traces en App Insights
Log Analytics Workspace ยท NutriBot ยท dependencies (tipo GenAI)
// Rendimiento del agente NutriPlannerAgent por modelo โ ultimas 24h dependencies | where type == "GenAI" | extend op = tostring(customDimensions["gen_ai.operation.name"]), agent = tostring(customDimensions["gen_ai.agent.name"]), model = tostring(customDimensions["gen_ai.request.model"]), t_in = toint(customDimensions["gen_ai.usage.input_tokens"]), t_out = toint(customDimensions["gen_ai.usage.output_tokens"]), tenant = tostring(customDimensions["tenant.id"]) | where agent == "NutriPlannerAgent" | summarize calls = count(), p95_ms = percentile(duration, 95), avg_in = avg(t_in), avg_out = avg(t_out), errors = countif(success == false) by op, model, tenant, bin(timestamp, 5m) | order by timestamp desc /* Resultado esperado (ultimas 5min): op model tenant calls p95_ms avg_in avg_out errors invoke_agent - clinica-0032 14 1840 - - 0 chat gpt-4o-mini clinica-0032 14 1720 312 148 0 execute_tool - clinica-0032 11 43 - - 0 */
Errores comunes evitados
โ Pitfall 1
Doble inicializacion โ importar el singleton en dos bundles produce page views duplicados. Solucion: un unico
appInsights.ts exportado.โ Pitfall 2
enableAutoRouteTracking + manual โ activar ambos duplica page views en React Router. Usamos el ReactPlugin con history para router tracking.
โ Pitfall 3
Contenido sensible GenAI โ
gen_ai.input.messages y gen_ai.output.messages son opt-in. No se loguean sin flag runtime explicitoy aprobacion.โ Pitfall 4
CORS distribuido โ requiere que
/api/nutriplanner exponga header Request-Context y acepte traceparent. Configurado en Azure API Management.โ Pitfall 5
React StrictMode โ doble invocacion de effects en dev puede llamar
loadAppInsights() dos veces. Protegido con export de modulo singleton.โ Pitfall 6
Token usage en invoke_agent โ los tokens van en el span
chat; se copian al parent invoke_agent solo cuando se conoce el total agregado definitivo.