@@ -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,209 @@ 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 ,
721+ child ,
722+ textComponentNames ,
723+ depth - 1 ,
724+ false
725+ ) ;
726+ if ( result === null ) {
727+ return null ;
728+ }
729+ texts . push ( ...result ) ;
730+ }
731+ } else if ( t . isJSXFragment ( child ) ) {
732+ const result = extractStaticTextFromChildren ( t , child , textComponentNames , depth , isRoot ) ;
733+ if ( result === null ) {
734+ return null ;
735+ }
736+ texts . push ( ...result ) ;
737+ } else if ( t . isJSXExpressionContainer ( child ) ) {
738+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
739+ return null ;
740+ }
741+ } else if ( t . isJSXSpreadChild ( child ) ) {
742+ return null ;
743+ }
744+ }
745+
746+ return texts ;
747+ }
748+
749+ /**
750+ * Recursively extracts static text from within a recognized text component.
751+ * Handles nested text components (e.g. <Text>Hello <Text style={bold}>world</Text></Text>)
752+ * which is the standard React Native pattern for inline styling.
753+ *
754+ * Returns null when any dynamic content is found, signaling bail-out.
755+ */
756+ function extractTextFromTextComponent (
757+ t : typeof Babel . types ,
758+ node : Babel . types . JSXElement | Babel . types . JSXFragment ,
759+ textComponentNames : string [ ]
760+ ) : string [ ] | null {
761+ const texts : string [ ] = [ ] ;
762+
763+ for ( const child of node . children ) {
764+ if ( t . isJSXText ( child ) ) {
765+ const trimmed = child . value . replace ( / \s + / g, " " ) . trim ( ) ;
766+ if ( trimmed ) {
767+ texts . push ( trimmed ) ;
768+ }
769+ } else if ( t . isJSXExpressionContainer ( child ) ) {
770+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
771+ return null ;
772+ }
773+ } else if ( t . isJSXElement ( child ) ) {
774+ const childName = getElementName ( t , child . openingElement ) ;
775+ if ( textComponentNames . includes ( childName ) ) {
776+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
777+ if ( innerTexts === null ) {
778+ return null ;
779+ }
780+ texts . push ( ...innerTexts ) ;
781+ } else {
782+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
783+ if ( innerTexts === null ) {
784+ return null ;
785+ }
786+ }
787+ } else if ( t . isJSXFragment ( child ) ) {
788+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
789+ if ( innerTexts === null ) {
790+ return null ;
791+ }
792+ texts . push ( ...innerTexts ) ;
793+ } else if ( t . isJSXSpreadChild ( child ) ) {
794+ return null ;
795+ }
796+ }
797+
798+ return texts ;
799+ }
800+
801+ function getElementName (
802+ t : typeof Babel . types ,
803+ openingElement : Babel . types . JSXOpeningElement
804+ ) : string {
805+ const name = openingElement . name ;
806+ if ( t . isJSXIdentifier ( name ) ) {
807+ return name . name ;
808+ }
809+ if ( t . isJSXMemberExpression ( name ) ) {
810+ return `${ getJSXMemberExpressionObjectName ( t , name . object ) } .${ name . property . name } ` ;
811+ }
812+ return "" ;
813+ }
814+
815+ /**
816+ * Injects a sentry-label attribute on the root JSX element of a component if
817+ * static text content can be extracted from its children.
818+ *
819+ * When the root is a JSX fragment, the first JSXElement child is used as the
820+ * target for both text extraction and attribute injection (since fragments
821+ * cannot carry attributes).
822+ */
823+ function maybeInjectSentryLabel ( context : JSXProcessingContext , jsxNode : Babel . NodePath ) : void {
824+ const { t, textComponentNames, ignoredComponents, componentName } = context ;
825+ const node = jsxNode . node ;
826+
827+ let targetElement : Babel . types . JSXElement ;
828+
829+ if ( t . isJSXElement ( node ) ) {
830+ targetElement = node ;
831+ } else if ( t . isJSXFragment ( node ) ) {
832+ const firstChild = node . children . find ( ( c ) : c is Babel . types . JSXElement => t . isJSXElement ( c ) ) ;
833+ if ( ! firstChild ) {
834+ return ;
835+ }
836+ targetElement = firstChild ;
837+ } else {
838+ return ;
839+ }
840+
841+ const targetElementName = getElementName ( t , targetElement . openingElement ) ;
842+
843+ if (
844+ ignoredComponents . some ( ( ignored ) => ignored === componentName || ignored === targetElementName )
845+ ) {
846+ return ;
847+ }
848+
849+ if (
850+ targetElement . openingElement . attributes . some (
851+ ( attr ) => t . isJSXAttribute ( attr ) && attr . name . name === SENTRY_LABEL_ATTRIBUTE
852+ )
853+ ) {
854+ return ;
855+ }
856+
857+ const texts = extractStaticTextFromChildren (
858+ t ,
859+ targetElement ,
860+ textComponentNames ,
861+ MAX_TEXT_SEARCH_DEPTH ,
862+ true
863+ ) ;
864+
865+ if ( texts === null ) {
866+ return ;
867+ }
868+
869+ let label = texts . join ( " " ) . replace ( / \s + / g, " " ) . trim ( ) ;
870+
871+ if ( ! label ) {
872+ return ;
873+ }
874+
875+ if ( label . length > MAX_LABEL_LENGTH ) {
876+ label = label . substring ( 0 , MAX_LABEL_LENGTH - 3 ) + "..." ;
877+ }
878+
879+ targetElement . openingElement . attributes . push (
880+ t . jSXAttribute ( t . jSXIdentifier ( SENTRY_LABEL_ATTRIBUTE ) , t . stringLiteral ( label ) )
881+ ) ;
882+ }
883+
661884const UNKNOWN_ELEMENT_NAME = "unknown" ;
0 commit comments