Skip to content

Commit bb309a8

Browse files
committed
fix(telemetria): heartbeat por sesión y watcher de build para sesiones SPA stale
Diagnóstico de pilot_events con 0 filas pese a un admin activo: el servidor siempre estuvo sano (probe autenticado en prod devolvió 201 e insertó fila), pero el bundle instrumentado sólo llega a pestañas recargadas; las sesiones SPA abiertas antes del deploy seguían corriendo código sin telemetría sin mecanismo de actualización. - beacon: un instrumentation_heartbeat por sesión montada, siempre incluso en /admin, para detectar un pipeline muerto en minutos - endpoint: ALLOWED_EVENTS acepta instrumentation_heartbeat - migración 057: relaja el check de pilot_events.event (aplicada en prod) - insights: adoption y últimos eventos filtran route_visited para no diluirse con heartbeats; contratos SQL regenerados en sincronía - useBuildWatcher: recarga la pestaña cuando el HTML servido referencia un main asset más nuevo (poll 5 min, guard anti-loop, jitter) - contrato pilot-telemetry-smoke: aserciones de heartbeat y 057 - tests: beacon batch con heartbeat único + test del watcher
1 parent 8a38155 commit bb309a8

11 files changed

Lines changed: 155 additions & 16 deletions

‎api/_migration-manifest.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const expectedMigrations=[
2-
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql'
2+
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql','057_pilot_heartbeat.sql'
33
] as const
44

55
export const migrationLandmarks=['parties','receptions','operators','historical_production_records','canonical_production_support_blocks','plant_stations','packing_units','label_templates','pallets','cold_runs','regulatory_holds','japan_export_release_evidence','lot_lifecycle_events','sea_urchin_external_references','gs1_identity_links'] as const

‎api/pilot-events.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type Request={method?:string;headers?:Record<string,string|string[]|undefined>;b
55
type Response={status:(code:number)=>Response;setHeader:(name:string,value:string)=>void;json:(body:unknown)=>void}
66
type BeaconEvent={event?:unknown;path?:unknown;locale?:unknown}
77

8-
const ALLOWED_EVENTS=['route_visited'] as const
8+
const ALLOWED_EVENTS=['route_visited','instrumentation_heartbeat'] as const
99
const MAX_BATCH=20
1010
const PATH_MAX=200
1111
const WINDOW_MS=60_000

‎api/pilot-insights.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ export default async function handler(request:Request,response:Response){
3131
limit 12`,
3232
sql`select to_char(created_at::date,'YYYY-MM-DD') "day",count(distinct operator_id)::int operators
3333
from pilot_events
34-
where created_at>=now()-interval '14 days'
34+
where event='route_visited' and created_at>=now()-interval '14 days'
3535
group by created_at::date
3636
order by created_at::date asc`,
3737
sql`select e.created_at,e.operator_id,o.full_name operator_name,e.role,e.event,e.path
3838
from pilot_events e left join operators o on o.id=e.operator_id
39+
where e.event='route_visited'
3940
order by e.id desc
4041
limit 50`,
4142
sql`select count(*)::int total,

‎db/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Desde `041_schema_migration_baseline.sql`, Neon conserva un registro explícito
8383
| `054_design_assets.sql` | registry de assets de diseño con lifecycle, provenance y binarios en Neon Object Storage |
8484
| `055_ml_human_feedback.sql` | feedback humano `good/bad` para Seafood AI/ML con comentario, contexto, revisión posterior y prohibición explícita de auto-retraining |
8585
| `056_pilot_events.sql` | telemetría de fricción de pilotaje: beacon de rutas visitadas autenticado por sesión, sin query strings ni PII, rol siempre server-side, sólo lectura para el criterio de reactivación UX |
86+
| `057_pilot_heartbeat.sql` | relaja el check de `pilot_events.event` para admitir `instrumentation_heartbeat` (uno por sesión montada) como red de detección de pipeline muerto, sin estado operacional ni PII |
8687

8788
El inventario anterior describe el repositorio actual. Si se agrega una migración, debe agregarse también a esta tabla; CI verifica esa correspondencia y que los landmarks del preflight sigan alineados con el manifiesto runtime.
8889

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- Pilot instrumentation heartbeat: allow one instrumentation_heartbeat per
2+
-- mounted session alongside route_visited. The heartbeat exists so a dead
3+
-- telemetry pipeline is detected within minutes (a session that never emits
4+
-- it is running stale or blocked client code), even when the operator parks
5+
-- on /admin surfaces where route visits are not instrumented. No operational
6+
-- state, no PII, role always stamped server-side from the session.
7+
8+
do $$
9+
declare
10+
event_check text;
11+
begin
12+
select con.conname into event_check
13+
from pg_constraint con
14+
join pg_class rel on rel.oid=con.conrelid
15+
join pg_namespace nsp on nsp.oid=rel.relnamespace
16+
where nsp.nspname='public' and rel.relname='pilot_events'
17+
and con.contype='c' and con.conname like '%event%'
18+
limit 1;
19+
if event_check is not null then
20+
execute format('alter table pilot_events drop constraint %I', event_check);
21+
end if;
22+
end $$;
23+
24+
alter table pilot_events add constraint pilot_events_event_check
25+
check (event in ('route_visited','instrumentation_heartbeat'));
26+
27+
insert into schema_migrations(migration_name,evidence_kind,applied_at,details)
28+
values(
29+
'057_pilot_heartbeat.sql',
30+
'applied',
31+
now(),
32+
jsonb_build_object(
33+
'attestation','pilot_instrumentation_heartbeat',
34+
'source','canonical_migration',
35+
'operational_writes',false,
36+
'query_strings_stored',false,
37+
'role_from_session_only',true
38+
)
39+
)
40+
on conflict(migration_name) do nothing;

‎scripts/pilot-insights-queries.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export const META={
1212

1313
export const QUERIES=[
1414
{name:"top_routes_7d_30d",template:"select path,\r\n count(*) filter (where created_at>=now()-interval '7 days')::int visits_7d,\r\n count(distinct operator_id) filter (where created_at>=now()-interval '7 days')::int operators_7d,\r\n count(*)::int visits_30d,\r\n count(distinct operator_id)::int operators_30d\r\n from pilot_events\r\n where event='route_visited' and created_at>=now()-interval '30 days'\r\n group by path\r\n order by visits_30d desc,path asc\r\n limit 12",sql:"select path,\r\n count(*) filter (where created_at>=now()-interval '7 days')::int visits_7d,\r\n count(distinct operator_id) filter (where created_at>=now()-interval '7 days')::int operators_7d,\r\n count(*)::int visits_30d,\r\n count(distinct operator_id)::int operators_30d\r\n from pilot_events\r\n where event='route_visited' and created_at>=now()-interval '30 days'\r\n group by path\r\n order by visits_30d desc,path asc\r\n limit 12"},
15-
{name:"daily_active_operators_14d",template:"select to_char(created_at::date,'YYYY-MM-DD') \"day\",count(distinct operator_id)::int operators\r\n from pilot_events\r\n where created_at>=now()-interval '14 days'\r\n group by created_at::date\r\n order by created_at::date asc",sql:"select to_char(created_at::date,'YYYY-MM-DD') \"day\",count(distinct operator_id)::int operators\r\n from pilot_events\r\n where created_at>=now()-interval '14 days'\r\n group by created_at::date\r\n order by created_at::date asc"},
16-
{name:"latest_events_50",template:"select e.created_at,e.operator_id,o.full_name operator_name,e.role,e.event,e.path\r\n from pilot_events e left join operators o on o.id=e.operator_id\r\n order by e.id desc\r\n limit 50",sql:"select e.created_at,e.operator_id,o.full_name operator_name,e.role,e.event,e.path\r\n from pilot_events e left join operators o on o.id=e.operator_id\r\n order by e.id desc\r\n limit 50"},
15+
{name:"daily_active_operators_14d",template:"select to_char(created_at::date,'YYYY-MM-DD') \"day\",count(distinct operator_id)::int operators\r\n from pilot_events\r\n where event='route_visited' and created_at>=now()-interval '14 days'\r\n group by created_at::date\r\n order by created_at::date asc",sql:"select to_char(created_at::date,'YYYY-MM-DD') \"day\",count(distinct operator_id)::int operators\r\n from pilot_events\r\n where event='route_visited' and created_at>=now()-interval '14 days'\r\n group by created_at::date\r\n order by created_at::date asc"},
16+
{name:"latest_events_50",template:"select e.created_at,e.operator_id,o.full_name operator_name,e.role,e.event,e.path\r\n from pilot_events e left join operators o on o.id=e.operator_id\r\n where e.event='route_visited'\r\n order by e.id desc\r\n limit 50",sql:"select e.created_at,e.operator_id,o.full_name operator_name,e.role,e.event,e.path\r\n from pilot_events e left join operators o on o.id=e.operator_id\r\n where e.event='route_visited'\r\n order by e.id desc\r\n limit 50"},
1717
{name:"ml_feedback_totals",template:"select count(*)::int total,\r\n count(*) filter (where rating='good')::int up,\r\n count(*) filter (where rating='bad')::int down\r\n from ml_feedback",sql:"select count(*)::int total,\r\n count(*) filter (where rating='good')::int up,\r\n count(*) filter (where rating='bad')::int down\r\n from ml_feedback"},
1818
{name:"ml_feedback_recent_downs",template:"select f.created_at,o.full_name operator_name,left(f.comment,300) comment,left(f.question,220) question,left(f.answer,400) answer\r\n from ml_feedback f left join operators o on o.id=f.operator_id\r\n where f.rating='bad'\r\n order by f.id desc\r\n limit 10",sql:"select f.created_at,o.full_name operator_name,left(f.comment,300) comment,left(f.question,220) question,left(f.answer,400) answer\r\n from ml_feedback f left join operators o on o.id=f.operator_id\r\n where f.rating='bad'\r\n order by f.id desc\r\n limit 10"}
1919
]

‎scripts/pilot-telemetry-smoke.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import {readFile} from 'node:fs/promises'
33
const failures=[]
44
const assert=(condition,message)=>{if(!condition)failures.push(message)}
55

6-
const [events,insights,beacon,access,vercel,app,migration]=await Promise.all([
6+
const [events,insights,beacon,access,vercel,app,migration,migration057]=await Promise.all([
77
readFile(new URL('../api/pilot-events.ts',import.meta.url),'utf8'),
88
readFile(new URL('../api/pilot-insights.ts',import.meta.url),'utf8'),
99
readFile(new URL('../src/hooks/usePilotTelemetry.ts',import.meta.url),'utf8'),
1010
readFile(new URL('../src/access.ts',import.meta.url),'utf8'),
1111
readFile(new URL('../vercel.json',import.meta.url),'utf8'),
1212
readFile(new URL('../src/App.tsx',import.meta.url),'utf8'),
1313
readFile(new URL('../db/migrations/056_pilot_events.sql',import.meta.url),'utf8'),
14+
readFile(new URL('../db/migrations/057_pilot_heartbeat.sql',import.meta.url),'utf8'),
1415
])
1516

1617
// Ingest endpoint: session-authenticated, session-stamped, rate-limited, minimal data.
@@ -20,6 +21,7 @@ assert(events.includes("response.status(401)")&&events.includes("response.status
2021
assert(events.includes("operator.role")&&!/body\.role|body\?\.role/.test(events),'pilot events role must come from the session, never the client')
2122
assert(events.includes("path.includes('?')")&&events.includes("path.includes('#')"),'pilot events must reject query strings and fragments in paths')
2223
assert(events.includes("'route_visited'"),'pilot events must whitelist the event kind')
24+
assert(events.includes("'instrumentation_heartbeat'"),'pilot events must accept the instrumentation heartbeat')
2325
assert(!/email|fullName|full_name|searchParams/.test(events),'pilot events must not read PII or query strings')
2426

2527
// Insights endpoint: admin-only read aggregate with zero writes.
@@ -34,6 +36,7 @@ assert(beacon.includes('useLocation')&&beacon.includes('pathname'),'beacon must
3436
assert(beacon.includes("typeof navigator.sendBeacon!=='function'"),'beacon must no-op when sendBeacon is unavailable')
3537
assert(beacon.includes("sendBeacon('/api/pilot-events'")&&beacon.includes('keepalive:true'),'beacon must use sendBeacon with a fetch-keepalive fallback')
3638
assert(beacon.includes("startsWith('/admin')"),'beacon must not instrument admin surfaces')
39+
assert(beacon.includes('instrumentation_heartbeat'),'beacon must emit one instrumentation heartbeat per session')
3740
assert(beacon.includes('pagehide'),'beacon must flush on pagehide so navigation is not lost')
3841
assert(!/URLSearchParams|location\.search|window\.location\.search/.test(beacon),'beacon must never read query strings')
3942
assert(app.includes('usePilotTelemetry(Boolean(operator))'),'beacon must be wired at the authenticated app level')
@@ -44,6 +47,8 @@ assert(vercel.includes('"/admin/pilotaje"')&&vercel.includes('"/index.html"'),'v
4447
assert(app.includes('path="/admin/pilotaje"')&&app.includes('gate("/admin/pilotaje",<AdminPilotaje/>)'),'App.tsx must register the gated pilotaje route')
4548
assert(migration.includes('create table if not exists pilot_events'),'migration 056 must create pilot_events')
4649
assert(migration.includes("insert into schema_migrations")&&migration.includes("'056_pilot_events.sql'")&&migration.includes("'applied'"),'migration 056 must record the canonical applied attestation')
50+
assert(migration057.includes('alter table pilot_events')&&migration057.includes("'instrumentation_heartbeat'"),'migration 057 must relax the pilot_events event check for the heartbeat')
51+
assert(migration057.includes("insert into schema_migrations")&&migration057.includes("'057_pilot_heartbeat.sql'")&&migration057.includes("'applied'"),'migration 057 must record the canonical applied attestation')
4752

4853
if(failures.length){
4954
console.error('Pilot telemetry contract FAILED')

0 commit comments

Comments
 (0)