@@ -45,10 +45,17 @@ const nativeComponentName = "dataSentryComponent";
4545const nativeElementName = "dataSentryElement" ;
4646const nativeSourceFileName = "dataSentrySourceFile" ;
4747
48+ const SENTRY_LABEL_ATTRIBUTE = "sentry-label" ;
49+ const MAX_LABEL_LENGTH = 64 ;
50+ const DEFAULT_TEXT_COMPONENT_NAMES = [ "Text" , "text" ] ;
51+ const MAX_TEXT_SEARCH_DEPTH = 3 ;
52+
4853interface AnnotationOpts {
4954 native ?: boolean ;
5055 "annotate-fragments" ?: boolean ;
5156 ignoredComponents ?: string [ ] ;
57+ autoInjectSentryLabel ?: boolean ;
58+ textComponentNames ?: string [ ] ;
5259}
5360
5461interface FragmentContext {
@@ -79,6 +86,10 @@ interface JSXProcessingContext {
7986 ignoredComponents : string [ ] ;
8087 /** Fragment context for identifying React fragments */
8188 fragmentContext ?: FragmentContext ;
89+ /** Whether to auto-inject sentry-label from static text children */
90+ autoInjectSentryLabel : boolean ;
91+ /** Component names whose JSXText children are considered text content */
92+ textComponentNames : string [ ] ;
8293}
8394
8495export { experimentalComponentNameAnnotatePlugin } from "./experimental" ;
@@ -170,6 +181,8 @@ function createJSXProcessingContext(
170181 attributeNames : attributeNamesFromState ( state ) ,
171182 ignoredComponents : state . opts . ignoredComponents ?? [ ] ,
172183 fragmentContext : state . sentryFragmentContext ,
184+ autoInjectSentryLabel : state . opts . autoInjectSentryLabel === true ,
185+ textComponentNames : state . opts . textComponentNames ?? DEFAULT_TEXT_COMPONENT_NAMES ,
173186 } ;
174187}
175188
@@ -261,6 +274,7 @@ function processJSX(
261274
262275 // Use provided componentName or fall back to context componentName
263276 const currentComponentName = componentName ?? context . componentName ;
277+ const isRootElement = componentName === undefined ;
264278
265279 // NOTE: I don't know of a case where `openingElement` would have more than one item,
266280 // but it's safer to always iterate
@@ -305,6 +319,10 @@ function processJSX(
305319 processJSX ( context , child , "" ) ;
306320 }
307321 } ) ;
322+
323+ if ( isRootElement && context . autoInjectSentryLabel ) {
324+ maybeInjectSentryLabel ( context , jsxNode ) ;
325+ }
308326}
309327
310328/**
@@ -658,4 +676,200 @@ function getJSXMemberExpressionObjectName(
658676 return UNKNOWN_ELEMENT_NAME ;
659677}
660678
679+ /**
680+ * Extracts static text content from JSX children, searching up to a depth limit.
681+ * Collects text from JSXText nodes of the root element and from recognized
682+ * text components (e.g. <Text>). Non-text custom components are traversed
683+ * but their own JSXText is not collected.
684+ *
685+ * Returns null when dynamic content is found anywhere in the subtree,
686+ * signaling that the entire label should be skipped.
687+ */
688+ function extractStaticTextFromChildren (
689+ t : typeof Babel . types ,
690+ node : Babel . types . JSXElement | Babel . types . JSXFragment ,
691+ textComponentNames : string [ ] ,
692+ depth : number ,
693+ isRoot : boolean
694+ ) : string [ ] | null {
695+ if ( depth <= 0 ) {
696+ return [ ] ;
697+ }
698+
699+ const texts : string [ ] = [ ] ;
700+
701+ for ( const child of node . children ) {
702+ if ( t . isJSXText ( child ) ) {
703+ if ( isRoot ) {
704+ const trimmed = child . value . replace ( / \s + / g, " " ) . trim ( ) ;
705+ if ( trimmed ) {
706+ texts . push ( trimmed ) ;
707+ }
708+ }
709+ } else if ( t . isJSXElement ( child ) ) {
710+ const childName = getElementName ( t , child . openingElement ) ;
711+
712+ if ( textComponentNames . includes ( childName ) ) {
713+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
714+ if ( innerTexts === null ) {
715+ return null ;
716+ }
717+ texts . push ( ...innerTexts ) ;
718+ } else {
719+ const result = extractStaticTextFromChildren (
720+ t , child , textComponentNames , depth - 1 , false
721+ ) ;
722+ if ( result === null ) {
723+ return null ;
724+ }
725+ texts . push ( ...result ) ;
726+ }
727+ } else if ( t . isJSXFragment ( child ) ) {
728+ const result = extractStaticTextFromChildren (
729+ t , child , textComponentNames , depth - 1 , false
730+ ) ;
731+ if ( result === null ) {
732+ return null ;
733+ }
734+ texts . push ( ...result ) ;
735+ } else if ( t . isJSXExpressionContainer ( child ) ) {
736+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
737+ return null ;
738+ }
739+ } else if ( t . isJSXSpreadChild ( child ) ) {
740+ return null ;
741+ }
742+ }
743+
744+ return texts ;
745+ }
746+
747+ /**
748+ * Recursively extracts static text from within a recognized text component.
749+ * Handles nested text components (e.g. <Text>Hello <Text style={bold}>world</Text></Text>)
750+ * which is the standard React Native pattern for inline styling.
751+ *
752+ * Returns null when any dynamic content is found, signaling bail-out.
753+ */
754+ function extractTextFromTextComponent (
755+ t : typeof Babel . types ,
756+ node : Babel . types . JSXElement ,
757+ textComponentNames : string [ ]
758+ ) : string [ ] | null {
759+ const texts : string [ ] = [ ] ;
760+
761+ for ( const child of node . children ) {
762+ if ( t . isJSXText ( child ) ) {
763+ const trimmed = child . value . replace ( / \s + / g, " " ) . trim ( ) ;
764+ if ( trimmed ) {
765+ texts . push ( trimmed ) ;
766+ }
767+ } else if ( t . isJSXExpressionContainer ( child ) ) {
768+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
769+ return null ;
770+ }
771+ } else if ( t . isJSXElement ( child ) ) {
772+ const childName = getElementName ( t , child . openingElement ) ;
773+ if ( textComponentNames . includes ( childName ) ) {
774+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
775+ if ( innerTexts === null ) {
776+ return null ;
777+ }
778+ texts . push ( ...innerTexts ) ;
779+ }
780+ } else if ( t . isJSXSpreadChild ( child ) ) {
781+ return null ;
782+ }
783+ }
784+
785+ return texts ;
786+ }
787+
788+ function getElementName (
789+ t : typeof Babel . types ,
790+ openingElement : Babel . types . JSXOpeningElement
791+ ) : string {
792+ const name = openingElement . name ;
793+ if ( t . isJSXIdentifier ( name ) ) {
794+ return name . name ;
795+ }
796+ if ( t . isJSXMemberExpression ( name ) ) {
797+ return `${ getJSXMemberExpressionObjectName ( t , name . object ) } .${ name . property . name } ` ;
798+ }
799+ return "" ;
800+ }
801+
802+ /**
803+ * Injects a sentry-label attribute on the root JSX element of a component if
804+ * static text content can be extracted from its children.
805+ *
806+ * When the root is a JSX fragment, the first JSXElement child is used as the
807+ * target for both text extraction and attribute injection (since fragments
808+ * cannot carry attributes).
809+ */
810+ function maybeInjectSentryLabel (
811+ context : JSXProcessingContext ,
812+ jsxNode : Babel . NodePath
813+ ) : void {
814+ const { t, textComponentNames, ignoredComponents, componentName } = context ;
815+ const node = jsxNode . node ;
816+
817+ let targetElement : Babel . types . JSXElement ;
818+
819+ if ( t . isJSXElement ( node ) ) {
820+ targetElement = node ;
821+ } else if ( t . isJSXFragment ( node ) ) {
822+ const firstChild = node . children . find ( ( c ) : c is Babel . types . JSXElement => t . isJSXElement ( c ) ) ;
823+ if ( ! firstChild ) {
824+ return ;
825+ }
826+ targetElement = firstChild ;
827+ } else {
828+ return ;
829+ }
830+
831+ const targetElementName = getElementName ( t , targetElement . openingElement ) ;
832+
833+ if (
834+ ignoredComponents . some (
835+ ( ignored ) => ignored === componentName || ignored === targetElementName
836+ )
837+ ) {
838+ return ;
839+ }
840+
841+ if (
842+ targetElement . openingElement . attributes . some (
843+ ( attr ) => t . isJSXAttribute ( attr ) && attr . name . name === SENTRY_LABEL_ATTRIBUTE
844+ )
845+ ) {
846+ return ;
847+ }
848+
849+ const texts = extractStaticTextFromChildren (
850+ t , targetElement , textComponentNames , MAX_TEXT_SEARCH_DEPTH , true
851+ ) ;
852+
853+ if ( texts === null ) {
854+ return ;
855+ }
856+
857+ let label = texts . join ( " " ) . replace ( / \s + / g, " " ) . trim ( ) ;
858+
859+ if ( ! label ) {
860+ return ;
861+ }
862+
863+ if ( label . length > MAX_LABEL_LENGTH ) {
864+ label = label . substring ( 0 , MAX_LABEL_LENGTH - 3 ) + "..." ;
865+ }
866+
867+ targetElement . openingElement . attributes . push (
868+ t . jSXAttribute (
869+ t . jSXIdentifier ( SENTRY_LABEL_ATTRIBUTE ) ,
870+ t . stringLiteral ( label )
871+ )
872+ ) ;
873+ }
874+
661875const UNKNOWN_ELEMENT_NAME = "unknown" ;
0 commit comments