@@ -45,10 +45,21 @@ 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+
53+ interface AutoInjectSentryLabelOpts {
54+ textComponentNames ?: string [ ] ;
55+ }
56+
4857interface AnnotationOpts {
4958 native ?: boolean ;
5059 "annotate-fragments" ?: boolean ;
5160 ignoredComponents ?: string [ ] ;
61+ /** @hidden */
62+ autoInjectSentryLabel ?: boolean | AutoInjectSentryLabelOpts ;
5263}
5364
5465interface FragmentContext {
@@ -79,6 +90,10 @@ interface JSXProcessingContext {
7990 ignoredComponents : string [ ] ;
8091 /** Fragment context for identifying React fragments */
8192 fragmentContext ?: FragmentContext ;
93+ /** Whether to auto-inject sentry-label from static text children */
94+ autoInjectSentryLabel : boolean ;
95+ /** Component names whose JSXText children are considered text content */
96+ textComponentNames : string [ ] ;
8297}
8398
8499export { experimentalComponentNameAnnotatePlugin } from "./experimental" ;
@@ -170,6 +185,11 @@ function createJSXProcessingContext(
170185 attributeNames : attributeNamesFromState ( state ) ,
171186 ignoredComponents : state . opts . ignoredComponents ?? [ ] ,
172187 fragmentContext : state . sentryFragmentContext ,
188+ autoInjectSentryLabel : ! ! state . opts . autoInjectSentryLabel ,
189+ textComponentNames :
190+ ( state . opts . autoInjectSentryLabel && typeof state . opts . autoInjectSentryLabel === "object"
191+ ? state . opts . autoInjectSentryLabel . textComponentNames
192+ : undefined ) ?? DEFAULT_TEXT_COMPONENT_NAMES ,
173193 } ;
174194}
175195
@@ -261,6 +281,7 @@ function processJSX(
261281
262282 // Use provided componentName or fall back to context componentName
263283 const currentComponentName = componentName ?? context . componentName ;
284+ const isRootElement = componentName === undefined ;
264285
265286 // NOTE: I don't know of a case where `openingElement` would have more than one item,
266287 // but it's safer to always iterate
@@ -305,6 +326,10 @@ function processJSX(
305326 processJSX ( context , child , "" ) ;
306327 }
307328 } ) ;
329+
330+ if ( isRootElement && context . autoInjectSentryLabel ) {
331+ maybeInjectSentryLabel ( context , jsxNode ) ;
332+ }
308333}
309334
310335/**
@@ -658,4 +683,209 @@ function getJSXMemberExpressionObjectName(
658683 return UNKNOWN_ELEMENT_NAME ;
659684}
660685
686+ /**
687+ * Extracts static text content from JSX children, searching up to a depth limit.
688+ * Collects text from JSXText nodes of the root element and from recognized
689+ * text components (e.g. <Text>). Non-text custom components are traversed
690+ * but their own JSXText is not collected.
691+ *
692+ * Returns null when dynamic content is found anywhere in the subtree,
693+ * signaling that the entire label should be skipped.
694+ */
695+ function extractStaticTextFromChildren (
696+ t : typeof Babel . types ,
697+ node : Babel . types . JSXElement | Babel . types . JSXFragment ,
698+ textComponentNames : string [ ] ,
699+ depth : number ,
700+ isRoot : boolean
701+ ) : string [ ] | null {
702+ if ( depth <= 0 ) {
703+ return [ ] ;
704+ }
705+
706+ const texts : string [ ] = [ ] ;
707+
708+ for ( const child of node . children ) {
709+ if ( t . isJSXText ( child ) ) {
710+ if ( isRoot ) {
711+ const trimmed = child . value . replace ( / \s + / g, " " ) . trim ( ) ;
712+ if ( trimmed ) {
713+ texts . push ( trimmed ) ;
714+ }
715+ }
716+ } else if ( t . isJSXElement ( child ) ) {
717+ const childName = getElementName ( t , child . openingElement ) ;
718+
719+ if ( textComponentNames . includes ( childName ) ) {
720+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
721+ if ( innerTexts === null ) {
722+ return null ;
723+ }
724+ texts . push ( ...innerTexts ) ;
725+ } else {
726+ const result = extractStaticTextFromChildren (
727+ t ,
728+ child ,
729+ textComponentNames ,
730+ depth - 1 ,
731+ false
732+ ) ;
733+ if ( result === null ) {
734+ return null ;
735+ }
736+ texts . push ( ...result ) ;
737+ }
738+ } else if ( t . isJSXFragment ( child ) ) {
739+ const result = extractStaticTextFromChildren ( t , child , textComponentNames , depth , isRoot ) ;
740+ if ( result === null ) {
741+ return null ;
742+ }
743+ texts . push ( ...result ) ;
744+ } else if ( t . isJSXExpressionContainer ( child ) ) {
745+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
746+ return null ;
747+ }
748+ } else if ( t . isJSXSpreadChild ( child ) ) {
749+ return null ;
750+ }
751+ }
752+
753+ return texts ;
754+ }
755+
756+ /**
757+ * Recursively extracts static text from within a recognized text component.
758+ * Handles nested text components (e.g. <Text>Hello <Text style={bold}>world</Text></Text>)
759+ * which is the standard React Native pattern for inline styling.
760+ *
761+ * Returns null when any dynamic content is found, signaling bail-out.
762+ */
763+ function extractTextFromTextComponent (
764+ t : typeof Babel . types ,
765+ node : Babel . types . JSXElement | Babel . types . JSXFragment ,
766+ textComponentNames : string [ ]
767+ ) : string [ ] | null {
768+ const texts : string [ ] = [ ] ;
769+
770+ for ( const child of node . children ) {
771+ if ( t . isJSXText ( child ) ) {
772+ const trimmed = child . value . replace ( / \s + / g, " " ) . trim ( ) ;
773+ if ( trimmed ) {
774+ texts . push ( trimmed ) ;
775+ }
776+ } else if ( t . isJSXExpressionContainer ( child ) ) {
777+ if ( ! t . isJSXEmptyExpression ( child . expression ) ) {
778+ return null ;
779+ }
780+ } else if ( t . isJSXElement ( child ) ) {
781+ const childName = getElementName ( t , child . openingElement ) ;
782+ if ( textComponentNames . includes ( childName ) ) {
783+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
784+ if ( innerTexts === null ) {
785+ return null ;
786+ }
787+ texts . push ( ...innerTexts ) ;
788+ } else {
789+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
790+ if ( innerTexts === null ) {
791+ return null ;
792+ }
793+ }
794+ } else if ( t . isJSXFragment ( child ) ) {
795+ const innerTexts = extractTextFromTextComponent ( t , child , textComponentNames ) ;
796+ if ( innerTexts === null ) {
797+ return null ;
798+ }
799+ texts . push ( ...innerTexts ) ;
800+ } else if ( t . isJSXSpreadChild ( child ) ) {
801+ return null ;
802+ }
803+ }
804+
805+ return texts ;
806+ }
807+
808+ function getElementName (
809+ t : typeof Babel . types ,
810+ openingElement : Babel . types . JSXOpeningElement
811+ ) : string {
812+ const name = openingElement . name ;
813+ if ( t . isJSXIdentifier ( name ) ) {
814+ return name . name ;
815+ }
816+ if ( t . isJSXMemberExpression ( name ) ) {
817+ return `${ getJSXMemberExpressionObjectName ( t , name . object ) } .${ name . property . name } ` ;
818+ }
819+ return "" ;
820+ }
821+
822+ /**
823+ * Injects a sentry-label attribute on the root JSX element of a component if
824+ * static text content can be extracted from its children.
825+ *
826+ * When the root is a JSX fragment, the first JSXElement child is used as the
827+ * target for both text extraction and attribute injection (since fragments
828+ * cannot carry attributes).
829+ */
830+ function maybeInjectSentryLabel ( context : JSXProcessingContext , jsxNode : Babel . NodePath ) : void {
831+ const { t, textComponentNames, ignoredComponents, componentName } = context ;
832+ const node = jsxNode . node ;
833+
834+ let targetElement : Babel . types . JSXElement ;
835+
836+ if ( t . isJSXElement ( node ) ) {
837+ targetElement = node ;
838+ } else if ( t . isJSXFragment ( node ) ) {
839+ const firstChild = node . children . find ( ( c ) : c is Babel . types . JSXElement => t . isJSXElement ( c ) ) ;
840+ if ( ! firstChild ) {
841+ return ;
842+ }
843+ targetElement = firstChild ;
844+ } else {
845+ return ;
846+ }
847+
848+ const targetElementName = getElementName ( t , targetElement . openingElement ) ;
849+
850+ if (
851+ ignoredComponents . some ( ( ignored ) => ignored === componentName || ignored === targetElementName )
852+ ) {
853+ return ;
854+ }
855+
856+ if (
857+ targetElement . openingElement . attributes . some (
858+ ( attr ) => t . isJSXAttribute ( attr ) && attr . name . name === SENTRY_LABEL_ATTRIBUTE
859+ )
860+ ) {
861+ return ;
862+ }
863+
864+ const texts = extractStaticTextFromChildren (
865+ t ,
866+ targetElement ,
867+ textComponentNames ,
868+ MAX_TEXT_SEARCH_DEPTH ,
869+ true
870+ ) ;
871+
872+ if ( texts === null ) {
873+ return ;
874+ }
875+
876+ let label = texts . join ( " " ) . replace ( / \s + / g, " " ) . trim ( ) ;
877+
878+ if ( ! label ) {
879+ return ;
880+ }
881+
882+ if ( label . length > MAX_LABEL_LENGTH ) {
883+ label = label . substring ( 0 , MAX_LABEL_LENGTH - 3 ) + "..." ;
884+ }
885+
886+ targetElement . openingElement . attributes . push (
887+ t . jSXAttribute ( t . jSXIdentifier ( SENTRY_LABEL_ATTRIBUTE ) , t . stringLiteral ( label ) )
888+ ) ;
889+ }
890+
661891const UNKNOWN_ELEMENT_NAME = "unknown" ;
0 commit comments