From c47c8c155e2ad3b417ee4798dec6b45728f0cbde Mon Sep 17 00:00:00 2001 From: Florencia Acosta Date: Wed, 24 Jul 2024 17:37:38 -0300 Subject: [PATCH] feat(instrumentation-react-native-navigation) tweaks and improvements + adding logs --- .gitignore | 1 - .../README.md | 21 ++- .../package.json | 10 +- .../components/NativeNavigationTracker.tsx | 33 +++- .../src/components/NavigationTracker.tsx | 31 +++- .../src/hooks/useAppStateListener.ts | 6 - .../src/hooks/useNativeNavigationTracker.ts | 37 +++- .../src/hooks/useNavigationTracker.ts | 40 ++++- .../src/types/navigation.ts | 21 +++ .../src/utils/hooks/useTrace.ts | 51 ++++-- .../src/utils/spanCreator.ts | 31 +++- .../test/NativeNavigationTracker.test.tsx | 123 ++++++++++--- .../test/NavigationTracker.test.tsx | 164 +++++++++++++----- .../test/hooks/useProvider.ts | 11 +- 14 files changed, 437 insertions(+), 143 deletions(-) diff --git a/.gitignore b/.gitignore index e7720f6f33..e60a42e810 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,6 @@ typings/ # Optional npm cache directory .npm -.npmrc # Optional eslint cache .eslintcache diff --git a/plugins/node/instrumentation-react-native-navigation/README.md b/plugins/node/instrumentation-react-native-navigation/README.md index 93571a79c4..45b0976984 100644 --- a/plugins/node/instrumentation-react-native-navigation/README.md +++ b/plugins/node/instrumentation-react-native-navigation/README.md @@ -7,13 +7,13 @@ This module provides instrumentation for [react-native/nagivation](https://react ## Installation ``` -npm i @embrace-io/react-native +npm i @opentelemetry/instrumentation-react-native-navigation @opentelemetry/api ``` or if you use yarn ``` -yarn add @embrace-io/react-native +yarn add @opentelemetry/instrumentation-react-native-navigation @opentelemetry/api ``` ## Supported Versions @@ -27,11 +27,14 @@ If you are using `expo-router` or `react-native/navigation` you need to wrap you ```javascript import {FC} from 'react'; import {Stack, useNavigationContainerRef} from 'expo-router'; -import {NavigationTracker} from '@embrace/react-native/experimental/navigation'; +import {NavigationTracker} from '@opentelemetry/instrumentation-react-native-navigation'; const App: FC = () => { - const navigationRef = useNavigationContainerRef(); // if you do not use `expo-router` the same hook is also available in `@react-navigation/native` since `expo-router` is built on top of it - const provider = useProvider(); // the provider is something you need to configure and pass down as prop into the `NavigationTracker` component + const navigationRef = useNavigationContainerRef(); // if you do not use `expo-router` the same hook is also available in `@react-navigation/native` since `expo-router` is built on top of it. Just make sure this ref is passed also to the navigation container at the root of your app (if not, the ref would be empty and you will get a console.warn message instead). + + const provider = useProvider(); // the provider is something you need to configure and pass down as prop into the `NavigationTracker` component (this hook is not part of the package, it is just used here as a reference) + // If your choise is not to pass any custom tracer provider, the component will use the global one. + // In both cases you have to make sure a tracer provider is registered BEFORE you attempt to record the first span. return ( @@ -73,7 +76,7 @@ Navigation.events().registerAppLaunchedListener(async () => { const HomeScreen: FC = () => { const navigationRef = useRef(Navigation.events()); // this is the important part. Make sure you pass a reference with the return of Navigation.events(); - const provider = useProvider(); // again, the provider should be passed down into the `NativeNavigationTracker` with the selected exporter and processor configured + const provider = useProvider(); // again, the provider should be passed down into the `NativeNavigationTracker` with the selected exporter and processor configured (this hook is not part of the package, it is just used here as a reference) return ( @@ -103,18 +106,20 @@ For instance, when the application starts and the user navigates to a new sectio traceId: 'a3280f7e6afab1e5b7f4ecfc12ec059f', parentId: undefined, traceState: undefined, - name: 'initial-test-view', + name: 'home', id: '270509763b408343', kind: 0, timestamp: 1718975153696000, duration: 252.375, - attributes: { initial_view: true }, + attributes: { launch: true, state.end: 'active' }, status: { code: 0 }, events: [], links: [] } ``` +If you dig into the attributes, `launch` refers to the moment the app is launched. It will be `true` only the first time the app mounts. Changing the status between background/foreground won't modify this attribute. For this case the `state.end` is used, and will and it can contain two possible values: `active` and `background`. + ### NOTE `useProvider` hook in this example returns an instance of a configured provided. diff --git a/plugins/node/instrumentation-react-native-navigation/package.json b/plugins/node/instrumentation-react-native-navigation/package.json index 60b36e11bf..e8f439d60c 100644 --- a/plugins/node/instrumentation-react-native-navigation/package.json +++ b/plugins/node/instrumentation-react-native-navigation/package.json @@ -1,9 +1,15 @@ { "name": "@opentelemetry/instrumentation-react-native-navigation", "version": "0.1.0", - "description": "OpenTelemetry instrumentation for the `@react-navigation/native` views for React Native applications", + "description": "OpenTelemetry instrumentation for `@react-navigation/native`, `expo-router` and `wix/react-native-navigation` route changes for React Native applications", "keywords": [ - "opentelemetry" + "navigation", + "react-native", + "instrumentation", + "nodejs", + "opentelemetry", + "profiling", + "tracing" ], "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/instrumentation-react-native-navigation#readme", "license": "Apache-2.0", diff --git a/plugins/node/instrumentation-react-native-navigation/src/components/NativeNavigationTracker.tsx b/plugins/node/instrumentation-react-native-navigation/src/components/NativeNavigationTracker.tsx index bd8eff4c58..85d8bbbdc0 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/components/NativeNavigationTracker.tsx +++ b/plugins/node/instrumentation-react-native-navigation/src/components/NativeNavigationTracker.tsx @@ -1,33 +1,50 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { forwardRef, ReactNode } from 'react'; -import { TracerProvider } from '@opentelemetry/api'; +import { TracerOptions, TracerProvider } from '@opentelemetry/api'; import useTrace from '../utils/hooks/useTrace'; import useNativeNavigationTracker, { NativeNavRef, } from '../hooks/useNativeNavigationTracker'; +import { NavigationTrackerConfig } from '../types/navigation'; -export type NativeNavigationTrackerRef = NativeNavRef; +type NativeNavigationTrackerRef = NativeNavRef; interface NativeNavigationTrackerProps { children: ReactNode; // selected provider, should be configured by the app consumer provider?: TracerProvider; + // in case a provider is passed + options?: TracerOptions; + config?: NavigationTrackerConfig; } const NativeNavigationTracker = forwardRef< NativeNavigationTrackerRef, NativeNavigationTrackerProps ->(({ children, provider }, ref) => { +>(({ children, provider, options, config }, ref) => { // Initializing a Trace instance - const tracer = useTrace( - { name: 'native-navigation', version: '0.1.0' }, - provider - ); + const tracer = useTrace(provider, options); - useNativeNavigationTracker(ref, tracer); + useNativeNavigationTracker(ref, tracer, config); return <>{children}; }); NativeNavigationTracker.displayName = 'NativeNavigationTracker'; export default NativeNavigationTracker; +export type { NativeNavigationTrackerRef }; diff --git a/plugins/node/instrumentation-react-native-navigation/src/components/NavigationTracker.tsx b/plugins/node/instrumentation-react-native-navigation/src/components/NavigationTracker.tsx index 27d54a7527..fe52b35177 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/components/NavigationTracker.tsx +++ b/plugins/node/instrumentation-react-native-navigation/src/components/NavigationTracker.tsx @@ -1,27 +1,44 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { forwardRef, ReactNode } from 'react'; -import { TracerProvider } from '@opentelemetry/api'; +import { TracerOptions, TracerProvider } from '@opentelemetry/api'; import useTrace from '../utils/hooks/useTrace'; import useNavigationTracker, { NavRef } from '../hooks/useNavigationTracker'; +import { NavigationTrackerConfig } from '../types/navigation'; type NavigationTrackerRef = NavRef; + interface NavigationTrackerProps { children: ReactNode; // selected provider, configured by the app consumer if global tracer is not enough provider?: TracerProvider; + // in case a provider is passed + options?: TracerOptions; + config?: NavigationTrackerConfig; } const NavigationTracker = forwardRef< NavigationTrackerRef, NavigationTrackerProps ->(({ children, provider }, ref) => { +>(({ children, provider, options, config }, ref) => { // Initializing a Trace instance - const tracer = useTrace( - { name: 'native-navigation', version: '0.1.0' }, - provider - ); + const tracer = useTrace(provider, options); - useNavigationTracker(ref, tracer); + useNavigationTracker(ref, tracer, config); return <>{children}; }); diff --git a/plugins/node/instrumentation-react-native-navigation/src/hooks/useAppStateListener.ts b/plugins/node/instrumentation-react-native-navigation/src/hooks/useAppStateListener.ts index ce100708a9..0e66913faf 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/hooks/useAppStateListener.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/hooks/useAppStateListener.ts @@ -16,12 +16,6 @@ import { AppState, AppStateStatus } from 'react-native'; import { useEffect } from 'react'; -// import pkg from 'react-native'; -// const { AppState } = pkg; - -// import react from 'react'; -// const { useEffect } = react; - type CallbackFn = (currentState: AppStateStatus) => void; const useAppStateListener = (callback?: CallbackFn) => { diff --git a/plugins/node/instrumentation-react-native-navigation/src/hooks/useNativeNavigationTracker.ts b/plugins/node/instrumentation-react-native-navigation/src/hooks/useNativeNavigationTracker.ts index 740a77e9ba..ea3b8fcb5a 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/hooks/useNativeNavigationTracker.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/hooks/useNativeNavigationTracker.ts @@ -23,7 +23,10 @@ import spanCreator, { } from '../utils/spanCreator'; import { TracerRef } from '../utils/hooks/useTrace'; import useSpan from '../utils/hooks/useSpan'; -import { INativeNavigationContainer } from '../types/navigation'; +import { + INativeNavigationContainer, + NavigationTrackerConfig, +} from '../types/navigation'; import useAppStateListener from './useAppStateListener'; @@ -31,8 +34,11 @@ export type NativeNavRef = INativeNavigationContainer; const useNativeNavigationTracker = ( ref: ForwardedRef, - tracer: TracerRef + tracer: TracerRef, + config?: NavigationTrackerConfig ) => { + const { attributes: customAttributes } = config ?? {}; + const navigationElRef = useMemo(() => { const isMutableRef = ref !== null && typeof ref !== 'function'; return isMutableRef ? ref.current : undefined; @@ -45,22 +51,34 @@ const useNativeNavigationTracker = ( useEffect(() => { if (!navigationElRef) { + console.warn( + 'Navigation ref is not available. Make sure this is properly configured.' + ); + // do nothing in case for some reason there is no navigationElRef return; } navigationElRef.registerComponentDidAppearListener(({ componentName }) => { if (!componentName) { + console.warn( + 'Navigation component name is not available. Make sure this is properly configured.' + ); + // do nothing in case for some reason there is no route return; } - spanCreator(tracer, span, navView, componentName); + spanCreator(tracer, span, navView, componentName, customAttributes); }); navigationElRef.registerComponentDidDisappearListener( ({ componentName }) => { if (!componentName) { + console.warn( + 'Navigation component name is not available. Make sure this is properly configured.' + ); + // do nothing in case for some reason there is no route return; } @@ -68,19 +86,24 @@ const useNativeNavigationTracker = ( spanEnd(span); } ); - }, [navigationElRef, span, tracer]); + }, [navigationElRef, span, tracer, customAttributes]); useEffect( () => () => { // making sure the final span is ended when the app is unmounted - spanEnd(span); + const isFinalView = true; + spanEnd(span, undefined, isFinalView); }, [span] ); const handleAppStateListener = useCallback( (currentState: AppStateStatus) => { - const appStateHandler = spanCreatorAppState(tracer, span); + const appStateHandler = spanCreatorAppState( + tracer, + span, + customAttributes + ); if (navView?.current === null) { return; @@ -88,7 +111,7 @@ const useNativeNavigationTracker = ( appStateHandler(navView?.current, currentState); }, - [span, tracer] + [span, tracer, customAttributes] ); useAppStateListener(handleAppStateListener); diff --git a/plugins/node/instrumentation-react-native-navigation/src/hooks/useNavigationTracker.ts b/plugins/node/instrumentation-react-native-navigation/src/hooks/useNavigationTracker.ts index cac4215737..914190f739 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/hooks/useNavigationTracker.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/hooks/useNavigationTracker.ts @@ -23,13 +23,22 @@ import spanCreator, { } from '../utils/spanCreator'; import { TracerRef } from '../utils/hooks/useTrace'; import useSpan from '../utils/hooks/useSpan'; -import { INavigationContainer } from '../types/navigation'; +import { + INavigationContainer, + NavigationTrackerConfig, +} from '../types/navigation'; import useAppStateListener from './useAppStateListener'; export type NavRef = INavigationContainer; -const useNavigationTracker = (ref: ForwardedRef, tracer: TracerRef) => { +const useNavigationTracker = ( + ref: ForwardedRef, + tracer: TracerRef, + config?: NavigationTrackerConfig +) => { + const { attributes: customAttributes } = config ?? {}; + const navigationElRef = useMemo(() => { const isMutableRef = ref !== null && typeof ref !== 'function'; return isMutableRef ? ref.current : undefined; @@ -42,31 +51,48 @@ const useNavigationTracker = (ref: ForwardedRef, tracer: TracerRef) => { const span = useSpan(); useEffect(() => { + if (!navigationElRef) { + console.warn( + 'Navigation ref is not available. Make sure this is properly configured.' + ); + + return; + } + if (navigationElRef) { navigationElRef.addListener('state', () => { const { name: routeName } = navigationElRef.getCurrentRoute() ?? {}; if (!routeName) { + console.warn( + 'Navigation route name is not available. Make sure this is properly configured.' + ); + // do nothing in case for some reason there is no route return; } - spanCreator(tracer, span, navView, routeName); + spanCreator(tracer, span, navView, routeName, customAttributes); }); } - }, [navigationElRef, span, tracer]); + }, [navigationElRef, span, tracer, customAttributes]); useEffect( () => () => { // making sure the final span is ended when the app is unmounted - spanEnd(span); + const isFinalView = true; + spanEnd(span, undefined, isFinalView); }, [span] ); const handleAppStateListener = useCallback( (currentState: AppStateStatus) => { - const appStateHandler = spanCreatorAppState(tracer, span); + const appStateHandler = spanCreatorAppState( + tracer, + span, + customAttributes + ); if (navView?.current === null) { return; @@ -74,7 +100,7 @@ const useNavigationTracker = (ref: ForwardedRef, tracer: TracerRef) => { appStateHandler(navView?.current, currentState); }, - [span, tracer] + [span, tracer, customAttributes] ); useAppStateListener(handleAppStateListener); diff --git a/plugins/node/instrumentation-react-native-navigation/src/types/navigation.ts b/plugins/node/instrumentation-react-native-navigation/src/types/navigation.ts index 75101d1c21..dd8967d448 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/types/navigation.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/types/navigation.ts @@ -1,3 +1,20 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Attributes } from '@opentelemetry/api'; + /* * Copyright The OpenTelemetry Authors * @@ -29,3 +46,7 @@ export interface INativeNavigationContainer { cb: (args: { componentName: string }) => void ) => void; } + +export interface NavigationTrackerConfig { + attributes?: Attributes; +} diff --git a/plugins/node/instrumentation-react-native-navigation/src/utils/hooks/useTrace.ts b/plugins/node/instrumentation-react-native-navigation/src/utils/hooks/useTrace.ts index bdb95df76a..c1e09db314 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/utils/hooks/useTrace.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/utils/hooks/useTrace.ts @@ -13,28 +13,53 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { MutableRefObject, useLayoutEffect, useRef } from 'react'; -import { trace, Tracer, TracerProvider } from '@opentelemetry/api'; - -interface ConfigArgs { - name: string; - version: string; -} +import { MutableRefObject, useEffect, useRef } from 'react'; +import { + trace, + Tracer, + TracerOptions, + TracerProvider, +} from '@opentelemetry/api'; +import { PACKAGE_NAME, PACKAGE_VERSION } from './../../version'; export type TracerRef = MutableRefObject; -const useTrace = (config: ConfigArgs, provider?: TracerProvider): TracerRef => { - const { name, version } = config; +const useTrace = ( + provider?: TracerProvider, + tracerOptions?: TracerOptions +): TracerRef => { const tracerRef = useRef(null); // using the layout effect to make sure the tracer is initialized before the component is rendered - useLayoutEffect(() => { + useEffect(() => { if (tracerRef.current === null) { + if (!provider) { + console.info('No TracerProvider found. Using global tracer instead.'); + } else { + console.info('TracerProvider. Using custom tracer.'); + } + tracerRef.current = provider - ? provider.getTracer(name, version) - : trace.getTracer(name, version); + ? provider.getTracer(PACKAGE_NAME, PACKAGE_VERSION, tracerOptions) + : // using global tracer provider + trace.getTracer(PACKAGE_NAME, PACKAGE_VERSION); + } + + // this is useful in cases where the provider is passed but it's still `null` or `undefined` (given a re-render or something specific of the lyfecycle of the app that implements the library) + if ( + tracerRef.current !== null && + provider !== undefined && + provider !== null + ) { + tracerRef.current = provider.getTracer( + PACKAGE_NAME, + PACKAGE_VERSION, + tracerOptions + ); + + console.info('Updated TracerProvider. Switching to the new instance.'); } - }, [name, provider, version]); + }, [provider, tracerOptions]); return tracerRef; }; diff --git a/plugins/node/instrumentation-react-native-navigation/src/utils/spanCreator.ts b/plugins/node/instrumentation-react-native-navigation/src/utils/spanCreator.ts index e540a6ce8d..c8d11daad8 100644 --- a/plugins/node/instrumentation-react-native-navigation/src/utils/spanCreator.ts +++ b/plugins/node/instrumentation-react-native-navigation/src/utils/spanCreator.ts @@ -18,17 +18,19 @@ import { MutableRefObject } from 'react'; import { TracerRef } from './hooks/useTrace'; import { SpanRef } from './hooks/useSpan'; +import { Attributes, trace, context } from '@opentelemetry/api'; const ATTRIBUTES = { initialView: 'launch', finalView: 'unmount', - appState: 'status.end', + appState: 'state.end', }; const spanStart = ( tracer: TracerRef, span: SpanRef, currentRouteName: string, + customAttributes?: Attributes, isLaunch?: boolean ) => { if (!tracer.current || span.current !== null) { @@ -39,14 +41,28 @@ const spanStart = ( // Starting the span span.current = tracer.current.startSpan(currentRouteName); + trace.setSpan(context.active(), span.current); + // it should create the first span knowing there is not a previous view span.current.setAttribute(ATTRIBUTES.initialView, !!isLaunch); + + if (customAttributes) { + span.current.setAttributes(customAttributes); + } }; -const spanEnd = (span: SpanRef, appState?: AppStateStatus) => { +const spanEnd = ( + span: SpanRef, + appState?: AppStateStatus, + isUnmount?: boolean +) => { if (span.current) { span.current.setAttribute(ATTRIBUTES.appState, appState ?? 'active'); + if (isUnmount) { + span.current.setAttribute(ATTRIBUTES.finalView, true); + } + span.current.end(); // make sure we destroy any existent span @@ -55,14 +71,14 @@ const spanEnd = (span: SpanRef, appState?: AppStateStatus) => { }; const spanCreatorAppState = - (tracer: TracerRef, span: SpanRef) => + (tracer: TracerRef, span: SpanRef, customAttributes?: Attributes) => (currentRouteName: string, currentState: AppStateStatus) => { if (currentState === null || currentState === undefined) { return; } if (currentState === 'active') { - spanStart(tracer, span, currentRouteName); + spanStart(tracer, span, currentRouteName, customAttributes); } else { spanEnd(span, currentState); } @@ -72,14 +88,15 @@ const spanCreator = ( tracer: TracerRef, span: SpanRef, view: MutableRefObject, - currentRouteName: string + currentRouteName: string, + customAttributes?: Attributes ) => { if (!tracer.current) { // do nothing in case for some reason the tracer is not initialized return; } - const isFirstView = view.current === null; + const isInitialView = view.current === null; const shouldEndCurrentSpan = view.current !== null && view.current !== currentRouteName; @@ -89,7 +106,7 @@ const spanCreator = ( spanEnd(span); } - spanStart(tracer, span, currentRouteName, isFirstView); + spanStart(tracer, span, currentRouteName, customAttributes, isInitialView); // last step before it changes the view view.current = currentRouteName; diff --git a/plugins/node/instrumentation-react-native-navigation/test/NativeNavigationTracker.test.tsx b/plugins/node/instrumentation-react-native-navigation/test/NativeNavigationTracker.test.tsx index b86ecce7eb..4dfc196e0c 100644 --- a/plugins/node/instrumentation-react-native-navigation/test/NativeNavigationTracker.test.tsx +++ b/plugins/node/instrumentation-react-native-navigation/test/NativeNavigationTracker.test.tsx @@ -1,13 +1,33 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { AppState } from 'react-native'; -import React, { useRef } from 'react'; +import React, { FC, useRef } from 'react'; import { render } from '@testing-library/react'; import { ATTRIBUTES } from '../src/utils/spanCreator'; import sinon from 'sinon'; import { NativeNavigationTracker } from '../src'; import useProvider from './hooks/useProvider'; +import api from '@opentelemetry/api'; +import type { Attributes } from '@opentelemetry/api'; import * as rnn from './helpers/react-native-navigation'; -const AppWithProvider = ({ shouldPassProvider = true }) => { +const AppWithProvider: FC<{ + shouldPassProvider?: boolean; + config?: { attributes?: Attributes }; +}> = ({ shouldPassProvider = true, config }) => { const { Navigation } = rnn; const provider = useProvider(); const ref = useRef(Navigation.events()); @@ -15,9 +35,10 @@ const AppWithProvider = ({ shouldPassProvider = true }) => { return ( - my app goes here + the app goes here ); }; @@ -28,8 +49,11 @@ describe('NativeNavigationTracker.tsx', function () { const mockDidDisappearListener = sandbox.spy(); let mockConsoleDir: sinon.SinonSpy; + let mockConsoleInfo: sinon.SinonSpy; let mockAddEventListener: sinon.SinonSpy; + let mockGlobalTracer: sinon.SinonSpy; + beforeEach(function () { const { Navigation } = rnn; sandbox.stub(Navigation, 'events').returns({ @@ -39,15 +63,28 @@ describe('NativeNavigationTracker.tsx', function () { mockAddEventListener = sandbox.spy(AppState, 'addEventListener'); mockConsoleDir = sandbox.spy(console, 'dir'); + mockConsoleInfo = sandbox.spy(console, 'info'); + + mockGlobalTracer = sandbox.spy(api.trace, 'getTracer'); }); afterEach(function () { sandbox.restore(); }); - it('should render a component that implements without passing a provider', async function () { - // const screen = - render(); + it('should render a component that implements without passing a provider', function () { + const screen = render(); + + sandbox.assert.calledOnceWithExactly( + mockConsoleInfo, + 'No TracerProvider found. Using global tracer instead.' + ); + + sandbox.assert.calledOnceWithExactly( + mockGlobalTracer, + '@opentelemetry/instrumentation-react-native-navigation', + sandbox.match.string + ); sandbox.assert.calledWith(mockDidAppearListener, sandbox.match.func); @@ -94,12 +131,14 @@ describe('NativeNavigationTracker.tsx', function () { sandbox.match({ depth: sandbox.match.number }) ); - // sandbox.assert.match(screen.getByText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); - it('should render a component that implements passing a custom provider', function () { - // const screen = - render(); + it('should render a component that implements passing a provider', function () { + const screen = render(); + + // should not call the global `getTracer` function since it should get the provider from props + sandbox.assert.notCalled(mockGlobalTracer); sandbox.assert.calledWith(mockDidAppearListener, sandbox.match.func); const mockDidAppearListenerCall = mockDidAppearListener.getCall(1).args[0]; @@ -145,12 +184,11 @@ describe('NativeNavigationTracker.tsx', function () { sandbox.match({ depth: sandbox.match.number }) ); - // sandbox.assert.match(screen.getByText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); it('should start and end spans when the app changes the status between foreground/background', function () { - // const screen = - render(); + const screen = render(); const mockDidAppearListenerCall = mockDidAppearListener.getCall(2).args[0]; const mockDidDisappearListenerCall = @@ -166,13 +204,13 @@ describe('NativeNavigationTracker.tsx', function () { mockConsoleDir, sandbox.match({ name: 'initial-view-after-launch', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: true, [ATTRIBUTES.appState]: 'background', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), sandbox.match({ depth: sandbox.match.number }) ); @@ -189,13 +227,13 @@ describe('NativeNavigationTracker.tsx', function () { mockConsoleDir, sinon.match({ name: 'initial-view-after-launch', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: false, [ATTRIBUTES.appState]: 'active', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), sandbox.match({ depth: sandbox.match.number }) ); @@ -206,18 +244,57 @@ describe('NativeNavigationTracker.tsx', function () { mockConsoleDir, sinon.match({ name: 'next-view', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: false, [ATTRIBUTES.appState]: 'background', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), sandbox.match({ depth: sandbox.match.number }) ); handleAppStateChange('active'); - // sandbox.assert.match(screen.getByText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); + }); + + it('should create spans with custom attributes', function () { + const screen = render( + + ); + + const mockDidAppearListenerCall = mockDidAppearListener.getCall(3).args[0]; + const mockDidDisappearListenerCall = + mockDidDisappearListener.getCall(3).args[0]; + + mockDidAppearListenerCall({ componentName: 'home-custom-attributes' }); + mockDidDisappearListenerCall({ componentName: 'home-custom-attributes' }); + + sandbox.assert.calledOnceWithMatch( + mockConsoleDir, + sandbox.match({ + name: 'home-custom-attributes', + traceId: sandbox.match.string, + attributes: { + [ATTRIBUTES.initialView]: true, + 'custom.attribute': 'custom.value', + 'custom.extra.attribute': 'custom.extra.value', + [ATTRIBUTES.appState]: 'active', + }, + timestamp: sandbox.match.number, + duration: sandbox.match.number, + }), + sandbox.match({ depth: sandbox.match.number }) + ); + + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); }); diff --git a/plugins/node/instrumentation-react-native-navigation/test/NavigationTracker.test.tsx b/plugins/node/instrumentation-react-native-navigation/test/NavigationTracker.test.tsx index cbdbcc9eca..44e4cc7d64 100644 --- a/plugins/node/instrumentation-react-native-navigation/test/NavigationTracker.test.tsx +++ b/plugins/node/instrumentation-react-native-navigation/test/NavigationTracker.test.tsx @@ -1,4 +1,19 @@ -import { ForwardedRef } from 'react'; +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FC, ForwardedRef } from 'react'; import React, { render } from '@testing-library/react'; import useProvider from './hooks/useProvider'; @@ -9,9 +24,14 @@ import sinon from 'sinon'; import { NavigationTracker } from '../src'; import { AppState } from 'react-native'; +import api from '@opentelemetry/api'; +import type { Attributes } from '@opentelemetry/api'; import * as rnn from './helpers/react-navigation-native'; -const AppWithProvider = ({ shouldPassProvider = true }) => { +const AppWithProvider: FC<{ + shouldPassProvider?: boolean; + config?: { attributes?: Attributes }; +}> = ({ shouldPassProvider = true, config }) => { const { useNavigationContainerRef } = rnn; const ref = useNavigationContainerRef(); const provider = useProvider(); @@ -19,9 +39,10 @@ const AppWithProvider = ({ shouldPassProvider = true }) => { return ( } + config={config} provider={shouldPassProvider ? provider.current : undefined} > - my app goes here + the app goes here ); }; @@ -34,38 +55,45 @@ describe('NavigationTracker.tsx', function () { let mockAddEventListener: sinon.SinonSpy; let mockConsoleDir: sinon.SinonSpy; + let mockConsoleInfo: sinon.SinonSpy; + + let mockGlobalTracer: sinon.SinonSpy; beforeEach(function () { - sandbox.stub(rnn, 'useNavigationContainerRef').callsFake(() => ({ - // @ts-ignore - current: { - getCurrentRoute: mockGetCurrentRoute, - addListener: mockAddListener, - dispatch: sandbox.stub(), - navigate: sandbox.stub(), - reset: sandbox.stub(), - goBack: sandbox.stub(), - isReady: sandbox.stub(), - canGoBack: sandbox.stub(), - setParams: sandbox.stub(), - isFocused: sandbox.stub(), - getId: sandbox.stub(), - getParent: sandbox.stub().returns('parentId'), - getState: sandbox.stub(), - }, - })); + sandbox.stub(rnn, 'useNavigationContainerRef').callsFake( + () => + ({ + current: { + getCurrentRoute: mockGetCurrentRoute, + addListener: mockAddListener, + }, + } as unknown as ReturnType) + ); mockAddEventListener = sandbox.spy(AppState, 'addEventListener'); mockConsoleDir = sandbox.spy(console, 'dir'); + mockConsoleInfo = sandbox.spy(console, 'info'); + + mockGlobalTracer = sandbox.spy(api.trace, 'getTracer'); }); afterEach(function () { sandbox.restore(); }); - it('should render a component that implements without passing a provider', async function () { - // const screen = - render(); + it('should render a component that implements without passing a provider', function () { + const screen = render(); + + sandbox.assert.calledOnceWithExactly( + mockConsoleInfo, + 'No TracerProvider found. Using global tracer instead.' + ); + + sandbox.assert.calledOnceWithExactly( + mockGlobalTracer, + '@opentelemetry/instrumentation-react-native-navigation', + sandbox.match.string + ); sandbox.assert.calledWith(mockAddListener, 'state', sandbox.match.func); const mockNavigationListenerCall = mockAddListener.getCall(0).args[1]; @@ -114,12 +142,14 @@ describe('NavigationTracker.tsx', function () { sandbox.match({ depth: sandbox.match.number }) ); - // sandbox.assert.match(screen.getByText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); - it('should render a component that implements passing a custom provider', function () { - // const screen = - render(); + it('should render a component that implements passing a provider', function () { + const screen = render(); + + // should not call the global `getTracer` function since it should get the provider from props + sandbox.assert.notCalled(mockGlobalTracer); sandbox.assert.calledWith(mockAddListener, 'state', sandbox.match.func); const mockNavigationListenerCall = mockAddListener.getCall(1).args[1]; @@ -157,24 +187,24 @@ describe('NavigationTracker.tsx', function () { mockConsoleDir, sandbox.match({ name: '2-second-view-test', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: false, [ATTRIBUTES.appState]: 'active', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), sandbox.match({ depth: sandbox.match.number }) ); - // sandbox.assert.match(screen.getByShadowText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); it('should start and end spans when the app changes the status between foreground/background', function () { // app launches - // const screen = - render(); + const screen = render(); + const mockNavigationListenerCall = mockAddListener.getCall(2).args[1]; const handleAppStateChange = mockAddEventListener.getCall(0).args[1]; @@ -191,15 +221,15 @@ describe('NavigationTracker.tsx', function () { mockConsoleDir, sandbox.match({ name: '3-initial-view-after-launch', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: true, [ATTRIBUTES.appState]: 'background', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), - sandbox.match({ depth: sinon.match.number }) + sandbox.match({ depth: sandbox.match.number }) ); // app goes back to foreground @@ -216,15 +246,15 @@ describe('NavigationTracker.tsx', function () { mockConsoleDir, sandbox.match({ name: '3-initial-view-after-launch', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: false, [ATTRIBUTES.appState]: 'active', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), - sandbox.match({ depth: sinon.match.number }) + sandbox.match({ depth: sandbox.match.number }) ); // app goes to background @@ -235,19 +265,59 @@ describe('NavigationTracker.tsx', function () { mockConsoleDir, sandbox.match({ name: '3-next-view', - traceId: sinon.match.string, + traceId: sandbox.match.string, attributes: { [ATTRIBUTES.initialView]: false, [ATTRIBUTES.appState]: 'background', }, - timestamp: sinon.match.number, - duration: sinon.match.number, + timestamp: sandbox.match.number, + duration: sandbox.match.number, }), - sandbox.match({ depth: sinon.match.number }) + sandbox.match({ depth: sandbox.match.number }) ); handleAppStateChange('active'); - // sandbox.assert.match(screen.getByShadowText('my app goes here'), true); + sandbox.assert.match(!!screen.getByText('the app goes here'), true); + }); + + it('should create spans with custom attributes', function () { + const screen = render( + + ); + + const mockNavigationListenerCall = mockAddListener.getCall(3).args[1]; + + mockGetCurrentRoute.returns({ name: 'home-custom-attributes' }); + mockNavigationListenerCall(); + + mockGetCurrentRoute.returns({ name: 'extra-custom-attributes' }); + mockNavigationListenerCall(); + + sandbox.assert.calledOnceWithMatch( + mockConsoleDir, + sandbox.match({ + name: 'home-custom-attributes', + traceId: sandbox.match.string, + attributes: { + [ATTRIBUTES.initialView]: true, + 'custom.attribute': 'custom.value', + 'custom.extra.attribute': 'custom.extra.value', + [ATTRIBUTES.appState]: 'active', + }, + timestamp: sandbox.match.number, + duration: sandbox.match.number, + }), + sandbox.match({ depth: sandbox.match.number }) + ); + + sandbox.assert.match(!!screen.getByText('the app goes here'), true); }); }); diff --git a/plugins/node/instrumentation-react-native-navigation/test/hooks/useProvider.ts b/plugins/node/instrumentation-react-native-navigation/test/hooks/useProvider.ts index 4ede3222a8..6e5f4c210c 100644 --- a/plugins/node/instrumentation-react-native-navigation/test/hooks/useProvider.ts +++ b/plugins/node/instrumentation-react-native-navigation/test/hooks/useProvider.ts @@ -21,13 +21,12 @@ import { } from '@opentelemetry/sdk-trace-base'; /** - * These are only for web, able to see logs coming through DevTools while developing. - * Example of a trace in console: + * Example of a trace shape: { "resource": { "attributes": { "service.name": "unknown_service", - "telemetry.sdk.language": "webjs", + "telemetry.sdk.language": "nodejs", "telemetry.sdk.name": "opentelemetry", "telemetry.sdk.version": "1.24.1" } @@ -39,8 +38,8 @@ import { "timestamp": 1717536927797000, "duration": 2820542.167, "attributes": { - "timestamp": 1717536927797, - "initial_view": true + "launch": true, + "state.end": 'active' }, "status": { "code": 0 @@ -58,12 +57,10 @@ const useProvider = (shouldShutDown = false) => { provider.current.addSpanProcessor(processor); provider.current.register(); - console.log('3 useeffect mount and configure'); }, []); useEffect(() => { const providerRef = provider.current; - console.log('3 main useeffect'); try { configure();