101101)
102102from .queue import DEFAULT_QUEUE_SIZE , LogQueue
103103from .serializable_data_class import SerializableDataClass
104+ from .span_customizer import SpanCustomizer , _customize_span_export , _get_span_customizers , _MaskingCustomizer
104105from .span_identifier_v3 import SpanComponentsV3 , SpanObjectTypeV3
105106from .span_identifier_v4 import SpanComponentsV4
106107from .span_origin import SpanOriginEnvironment , detect_environment , merge_span_origin_context
124125from .xact_ids import prettify_xact
125126
126127
127- # Fields that should be passed to the masking function
128- # Note: "tags" field is intentionally excluded, but can be added if needed
129- REDACTION_FIELDS = ["input" , "output" , "expected" , "metadata" , "context" , "scores" , "metrics" ]
130-
131128DATA_API_VERSION = 2
132129LOGS3_OVERFLOW_REFERENCE_TYPE = "logs3_overflow"
133130# 6 MB for the AWS lambda gateway (from our own testing).
@@ -1002,40 +999,6 @@ def utf8_byte_length(value: str) -> int:
1002999 return len (value .encode ("utf-8" ))
10031000
10041001
1005- class _MaskingError :
1006- """Internal class to signal masking errors that need special handling."""
1007-
1008- def __init__ (self , field_name : str , error_type : str ):
1009- self .field_name = field_name
1010- self .error_type = error_type
1011- self .error_msg = f"ERROR: Failed to mask field '{ field_name } ' - { error_type } "
1012-
1013-
1014- def _apply_masking_to_field (masking_function : Callable [[Any ], Any ], data : Any , field_name : str ) -> Any :
1015- """Apply masking function to data and handle errors gracefully.
1016-
1017- If the masking function raises an exception, returns an error message.
1018- Returns _MaskingError for scores/metrics fields to signal they should be dropped.
1019- """
1020- try :
1021- return masking_function (data )
1022- except Exception as mask_error :
1023- # Return a generic error message without the stack trace to avoid leaking PII
1024- error_type = type (mask_error ).__name__
1025-
1026- # For scores and metrics fields, return a special error object
1027- # to signal the field should be dropped and error logged
1028- if field_name in ["scores" , "metrics" ]:
1029- return _MaskingError (field_name , error_type )
1030-
1031- # For metadata field that expects dict type, return a dict with error key
1032- if field_name == "metadata" :
1033- return {"error" : f"ERROR: Failed to mask field '{ field_name } ' - { error_type } " }
1034-
1035- # For other fields, return the error message as a string
1036- return f"ERROR: Failed to mask field '{ field_name } ' - { error_type } "
1037-
1038-
10391002class _BackgroundLogger (ABC ):
10401003 @abstractmethod
10411004 def log (self , * args : LazyValue [dict [str , Any ]]) -> None :
@@ -1050,7 +1013,7 @@ class _MemoryBackgroundLogger(_BackgroundLogger):
10501013 def __init__ (self ):
10511014 self .lock = threading .Lock ()
10521015 self .logs = []
1053- self .masking_function : Callable [[ Any ], Any ] | None = None
1016+ self ._export_customizers : tuple [ SpanCustomizer , ...] = ()
10541017 self .upload_attempts : list [BaseAttachment ] = [] # Track upload attempts
10551018
10561019 def enforce_queue_size_limit (self , enforce : bool ) -> None :
@@ -1062,7 +1025,7 @@ def log(self, *args: LazyValue[dict[str, Any]]) -> None:
10621025
10631026 def set_masking_function (self , masking_function : Callable [[Any ], Any ] | None ) -> None :
10641027 """Set the masking function for the memory logger."""
1065- self .masking_function = masking_function
1028+ self ._export_customizers = ( _MaskingCustomizer ( masking_function ),) if masking_function is not None else ()
10661029
10671030 def flush (self , batch_size : int | None = None ):
10681031 """Flush the memory logger, extracting attachments and tracking upload attempts."""
@@ -1093,28 +1056,8 @@ def pop(self):
10931056 # here
10941057 batch = merge_row_batch (logs )
10951058
1096- # Apply masking after merge, similar to HTTPBackgroundLogger
1097- if self .masking_function :
1098- for i in range (len (batch )):
1099- item = batch [i ]
1100- masked_item = item .copy ()
1101-
1102- # Only mask specific fields if they exist
1103- for field in REDACTION_FIELDS :
1104- if field in item :
1105- masked_value = _apply_masking_to_field (self .masking_function , item [field ], field )
1106- if isinstance (masked_value , _MaskingError ):
1107- # Drop the field and add error message
1108- if field in masked_item :
1109- del masked_item [field ]
1110- if "error" in masked_item :
1111- masked_item ["error" ] = f"{ masked_item ['error' ]} ; { masked_value .error_msg } "
1112- else :
1113- masked_item ["error" ] = masked_value .error_msg
1114- else :
1115- masked_item [field ] = masked_value
1116-
1117- batch [i ] = masked_item
1059+ if self ._export_customizers :
1060+ batch = [_customize_span_export (item , self ._export_customizers ) for item in batch ]
11181061
11191062 return batch
11201063
@@ -1129,7 +1072,7 @@ def pop(self):
11291072class _HTTPBackgroundLogger :
11301073 def __init__ (self , api_conn : LazyValue [HTTPConnection ]):
11311074 self .api_conn = api_conn
1132- self .masking_function : Callable [[ Any ], Any ] | None = None
1075+ self ._export_customizers : tuple [ SpanCustomizer , ...] = ()
11331076 self .outfile = sys .stderr
11341077 self .flush_lock = threading .RLock ()
11351078 self ._max_request_size_override : int | None = None
@@ -1318,28 +1261,9 @@ def _unwrap_lazy_values(
13181261 unwrapped_items = [item .get () for item in wrapped_items ]
13191262 merged_items = merge_row_batch (unwrapped_items )
13201263
1321- # Apply masking after merging but before sending to backend
1322- if self .masking_function :
1323- for item_idx in range (len (merged_items )):
1324- item = merged_items [item_idx ]
1325- masked_item = item .copy ()
1326-
1327- # Only mask specific fields if they exist
1328- for field in REDACTION_FIELDS :
1329- if field in item :
1330- masked_value = _apply_masking_to_field (self .masking_function , item [field ], field )
1331- if isinstance (masked_value , _MaskingError ):
1332- # Drop the field and add error message
1333- if field in masked_item :
1334- del masked_item [field ]
1335- if "error" in masked_item :
1336- masked_item ["error" ] = f"{ masked_item ['error' ]} ; { masked_value .error_msg } "
1337- else :
1338- masked_item ["error" ] = masked_value .error_msg
1339- else :
1340- masked_item [field ] = masked_value
1341-
1342- merged_items [item_idx ] = masked_item
1264+ # Logger-local hooks run after instrumentation hooks and merging.
1265+ if self ._export_customizers :
1266+ merged_items = [_customize_span_export (item , self ._export_customizers ) for item in merged_items ]
13431267
13441268 attachments : list ["BaseAttachment" ] = []
13451269 for item in merged_items :
@@ -1553,7 +1477,7 @@ def internal_replace_api_conn(self, api_conn: HTTPConnection):
15531477
15541478 def set_masking_function (self , masking_function : Callable [[Any ], Any ] | None ):
15551479 """Set or update the masking function."""
1556- self .masking_function = masking_function
1480+ self ._export_customizers = ( _MaskingCustomizer ( masking_function ),) if masking_function is not None else ()
15571481
15581482
15591483def _internal_reset_global_state () -> None :
@@ -2565,6 +2489,8 @@ def set_masking_function(masking_function: Callable[[Any], Any] | None) -> None:
25652489 """
25662490 Set a global masking function that will be applied to all logged data before sending to Braintrust.
25672491 The masking function will be applied after records are merged but before they are sent to the backend.
2492+ Internally, masking is a logger-local export customizer that runs after instrumentation
2493+ customizers and also covers manually logged records.
25682494
25692495 :param masking_function: A function that takes a JSON-serializable object and returns a masked version.
25702496 Set to None to disable masking.
@@ -4975,36 +4901,68 @@ def log_internal(self, event: dict[str, Any] | None = None, internal_data: dict[
49754901 if serializable_partial_record .get ("metrics" , {}).get ("end" ) is not None :
49764902 self ._logged_end_time = serializable_partial_record ["metrics" ]["end" ]
49774903
4978- # Write to local span cache for scorer access
4979- # Only cache experiment spans - regular logs don't need caching
4980- if self .parent_object_type == SpanObjectTypeV3 .EXPERIMENT :
4904+ # Snapshot at log time so the span cache and the export agree on whether
4905+ # (and how) this record is customized.
4906+ customizers = _get_span_customizers () if self ._instrumentation != "braintrust-python-logger" else ()
4907+ pending_cache_key = (
4908+ object ()
4909+ if customizers
4910+ and self .parent_object_type == SpanObjectTypeV3 .EXPERIMENT
4911+ and not self .state .span_cache .disabled
4912+ else None
4913+ )
4914+
4915+ def write_span_cache (record : dict [str , Any ]) -> None :
4916+ # Write to local span cache for scorer access
4917+ # Only cache experiment spans - regular logs don't need caching
4918+ if self .parent_object_type != SpanObjectTypeV3 .EXPERIMENT :
4919+ return
49814920 from braintrust .span_cache import CachedSpan
49824921
49834922 cached_span = CachedSpan (
49844923 span_id = self .span_id ,
4985- input = serializable_partial_record .get ("input" ),
4986- output = serializable_partial_record .get ("output" ),
4987- metadata = serializable_partial_record .get ("metadata" ),
4924+ input = record .get ("input" ),
4925+ output = record .get ("output" ),
4926+ metadata = record .get ("metadata" ),
49884927 span_parents = self .span_parents ,
4989- span_attributes = serializable_partial_record .get ("span_attributes" ),
4990- error = serializable_partial_record .get ("error" ),
4991- metrics = serializable_partial_record .get ("metrics" ),
4992- tags = serializable_partial_record .get ("tags" ),
4928+ span_attributes = record .get ("span_attributes" ),
4929+ error = record .get ("error" ),
4930+ metrics = record .get ("metrics" ),
4931+ tags = record .get ("tags" ),
49934932 )
49944933 self .state .span_cache .queue_write (self .root_span_id , self .span_id , cached_span )
49954934
4935+ # Customized records are cached after export customization instead, so
4936+ # local scorers never see content that a customizer redacted.
4937+ if not customizers :
4938+ write_span_cache (serializable_partial_record )
4939+
49964940 def compute_record () -> dict [str , Any ]:
49974941 exporter = _get_exporter ()
4998- return dict (
4942+ record = dict (
49994943 ** serializable_partial_record ,
50004944 ** {k : v .get () for k , v in lazy_partial_record .items ()},
50014945 ** exporter (
50024946 object_type = self .parent_object_type ,
50034947 object_id = self .parent_object_id .get (),
50044948 ).object_id_fields (),
50054949 )
5006-
5007- self .state .global_bg_logger ().log (LazyValue (compute_record , use_mutex = False ))
4950+ # Resolve and customize inside the cached LazyValue: every incremental
4951+ # instrumentation record is transformed once, before the background
4952+ # logger merges, masks, extracts attachments, or retries delivery.
4953+ if customizers :
4954+ record = _customize_span_export (record , customizers )
4955+ write_span_cache (record )
4956+ if pending_cache_key is not None :
4957+ self .state .span_cache ._forget_pending_record (self .root_span_id , pending_cache_key )
4958+ return record
4959+
4960+ # Cache readers and the publisher share resolution, including the cache
4961+ # write, so concurrent reads cannot customize a record twice.
4962+ lazy_record = LazyValue (compute_record , use_mutex = pending_cache_key is not None )
4963+ if pending_cache_key is not None :
4964+ self .state .span_cache ._track_pending_record (self .root_span_id , pending_cache_key , lazy_record )
4965+ self .state .global_bg_logger ().log (lazy_record )
50084966
50094967 def log_feedback (self , ** event : Any ) -> None :
50104968 return _log_feedback_impl (
0 commit comments