2424from policyengine .utils .data .datasets import get_default_dataset
2525import json
2626import datetime
27+ import hashlib
28+ import uuid
2729from typing import Literal , Any , Optional , Annotated
2830from dotenv import load_dotenv
2931from pydantic import BaseModel
@@ -357,7 +359,6 @@ def _determine_impact_action(
357359 self ,
358360 most_recent_impact : dict | None ,
359361 ) -> ImpactAction :
360-
361362 if not most_recent_impact :
362363 return ImpactAction .CREATE
363364
@@ -448,7 +449,6 @@ def _handle_computing_impact(
448449 setup_options : EconomicImpactSetupOptions ,
449450 most_recent_impact : dict ,
450451 ) -> EconomicImpactResult :
451-
452452 execution = simulation_api .get_execution_by_id (
453453 most_recent_impact ["execution_id" ]
454454 )
@@ -484,17 +484,21 @@ def _handle_create_impact(
484484 data_version = setup_options .data_version ,
485485 )
486486
487+ sim_params = sim_config .model_dump (mode = "json" )
488+ telemetry = self ._build_simulation_telemetry (
489+ setup_options = setup_options ,
490+ sim_config = sim_params ,
491+ )
492+
487493 logger .log_struct (
488494 {
489495 "message" : "Setting up sim API job" ,
496+ "run_id" : telemetry ["run_id" ],
490497 ** setup_options .model_dump (),
491498 }
492499 )
493500
494- # Build params with metadata for Logfire tracing in the simulation API.
495- # The _metadata field will be captured by the Logfire span before
496- # SimulationOptions validation (which silently ignores extra fields).
497- sim_params = sim_config .model_dump ()
501+ # Preserve both legacy metadata and the new telemetry envelope.
498502 sim_params ["_metadata" ] = {
499503 "reform_policy_id" : setup_options .reform_policy_id ,
500504 "baseline_policy_id" : setup_options .baseline_policy_id ,
@@ -505,14 +509,17 @@ def _handle_create_impact(
505509 "dataset" : setup_options .dataset ,
506510 "resolved_app_name" : setup_options .runtime_app_name ,
507511 }
512+ sim_params ["_telemetry" ] = telemetry
508513
509514 sim_api_execution = simulation_api .run (sim_params )
510515 execution_id = simulation_api .get_execution_id (sim_api_execution )
516+ run_id = getattr (sim_api_execution , "run_id" , None ) or telemetry ["run_id" ]
511517
512518 progress_log = {
513519 ** setup_options .model_dump (),
514520 "message" : "Sim API job started" ,
515521 "execution_id" : execution_id ,
522+ "run_id" : run_id ,
516523 }
517524 logger .log_struct (progress_log , severity = "INFO" )
518525
@@ -759,6 +766,73 @@ def _setup_data(
759766 )
760767 raise
761768
769+ def _build_simulation_telemetry (
770+ self ,
771+ setup_options : EconomicImpactSetupOptions ,
772+ sim_config : dict [str , Any ],
773+ ) -> dict [str , Any ]:
774+ simulation_kind , geography_type , geography_code = (
775+ self ._classify_simulation_geography (
776+ country_id = setup_options .country_id ,
777+ region = setup_options .region ,
778+ )
779+ )
780+
781+ return {
782+ "run_id" : str (uuid .uuid4 ()),
783+ "process_id" : setup_options .process_id ,
784+ "traceparent" : self ._get_current_traceparent (),
785+ "requested_at" : datetime .datetime .now (datetime .UTC ).isoformat (),
786+ "simulation_kind" : simulation_kind ,
787+ "geography_code" : geography_code ,
788+ "geography_type" : geography_type ,
789+ "config_hash" : self ._stable_config_hash (sim_config ),
790+ "capture_mode" : "disabled" ,
791+ }
792+
793+ def _classify_simulation_geography (
794+ self ,
795+ country_id : str ,
796+ region : str ,
797+ ) -> tuple [str , str , str ]:
798+ if region == country_id :
799+ return "national" , "national" , country_id
800+
801+ if "/" not in region :
802+ return "other" , "other" , region
803+
804+ geography_type , geography_code = region .split ("/" , maxsplit = 1 )
805+ simulation_kind = (
806+ "district" if geography_type == "congressional_district" else geography_type
807+ )
808+ return simulation_kind , geography_type , geography_code
809+
810+ def _stable_config_hash (self , payload : dict [str , Any ]) -> str :
811+ encoded = json .dumps (
812+ payload ,
813+ sort_keys = True ,
814+ separators = ("," , ":" ),
815+ default = str ,
816+ ).encode ("utf-8" )
817+ return f"sha256:{ hashlib .sha256 (encoded ).hexdigest ()} "
818+
819+ def _get_current_traceparent (self ) -> str | None :
820+ try :
821+ from opentelemetry import trace
822+ except Exception :
823+ return None
824+
825+ span = trace .get_current_span ()
826+ span_context = span .get_span_context ()
827+ if not getattr (span_context , "is_valid" , False ):
828+ return None
829+
830+ trace_flags = int (getattr (span_context , "trace_flags" , 0 ))
831+ return (
832+ f"00-{ span_context .trace_id :032x} -"
833+ f"{ span_context .span_id :016x} -{ trace_flags :02x} "
834+ )
835+
762836 # Note: The following methods that interface with the ReformImpactsService
763837 # are written separately because the service relies upon mutating an original
764838 # 'computing' record to 'ok' or 'error' status, rather than creating a new record.
0 commit comments