Logging Tips for Frontend

solleedata

Sol Lee

Posted on April 27, 2024

Logging Tips for Frontend

As you develop a product, sometimes you need to know how users use it. For this, we refer to logged data. You can analyze the user's behavior with the data collected through logging, check the results of the A/B test, or debug it in an environment that is difficult to reproduce.

Before introducing how we improved our logging, let's have a look at the methods we used. Here's the code for the page where users register credit cards.

import { Button, useToaster } from '@tossteam/tds';
// ...

const REGISTER_CARD_SCREEN_LOG_ID = 123;
const REGISTER_CARD_CLICK_LOG_ID = 456;
const REGISTER_CARD_POPUP_LOG_ID = 789;

const PAGE_TITLE  = 'Write card information';

function RegisterCardPage() {
  // ...
  const toast = useToaster();
  const logger = useLogger();

  useEffect(() => {
    // Request screen logs
    logger.screen({
      logId: REGISTER_CARD_SCREEN_LOG_ID,
      params: { title: PAGE_TITLE } 
    });
  },[])

  return (
    <>
    // ...
    <Button onClick={async () => {
      try {
        // Request click logs
        logger.click({ 
          logId: REGISTER_CARD_CLICK_LOG_ID,
          params: { title: PAGE_TITLE, button: '다음' }
         });
        await registerCard(cardInfo);
        // ...    
        } catch (error) {
        // Request toast popup logs
        logger.popup({
          logId: REGISTER_CARD_POPUP_LOG_ID,
          type: 'toast',
          params: { title: PAGE_TITLE, message: error.message }
        });
        toast.open(error.message);
       }
     }}>
     Next
    </Button>
   </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code above, there are screen logs that are taken when a user accesses a page, click logs which are taken when a user clicks, and pop-up logs, taken when a user sees toast or modal.

These logs were written by the front-end developers themselves in the code. Whenever a defined action occurs, logging takes place with the logId(log identifier) just before the action.

After some improvements, the code above became like this:

import { Button, useToaster } from '@tosspayments/log-tds';
**import { LogScreen } from '@tosspayments/log-core';**

function RegisterCardPage() {
  const toast = useToaster();

  return (  
    <LogScreen title="Write card information">   
     // ...
      <Button onClick={async () => {
       try {   
         await registerCard(cardInfo);
         // ...
        } catch (error) {
          toast.open(error.message);
        }
      }}> 
        Next
      </Button>
    </LogScreen>  
  );
}
Enter fullscreen mode Exit fullscreen mode

Can you see the logging logic have disappeared? And the import statement has changed. Also, the remaining logging logic has been written declaratively, and you can see that even constants such as LOG_ID and PAGE_TITLE have disappeared.

so, how did we make the improvements?

What part of the example code we saw above bothered you the most?

That's right. The constant value called LOG_ID that appears from the beginning of the code must have bothered you. If you don't know our logging system, you might wonder why this value is needed.

const REGISTER_CARD_SCREEN_LOG_ID = 123;
const REGISTER_CARD_CLICK_LOG_ID = 456;
const REGISTER_CARD_POPUP_LOG_ID = 789;
Enter fullscreen mode Exit fullscreen mode

In short, logId is a log identifier. It is an id value that identifies multiple logs, which is essential when requesting logs. So front-end developers had to repeatedly copy and paste logId from documents into code every time they applied logs.

I thought it was cumbersome to know the logId to log. So I removed the logId and created a log identifier that front-end developers don't need to know. By combining various information such as the service name, the route of the page, and the logging type and event type, we automatically created an identifier and named it logName. Instead of developers defining and delivering it in advance, the application creates its own identifier and uses it to log.

function createLogName({ logType, eventType }: { logType: LogType, eventType?: EventType }) {
  const serviceName = packageJson.name;
  const routerPath = location.pathname;

  const screenName = `payments_${serviceName}__${rotuerPath}`;
  const eventName = `::${eventType}__${eventName}`;

  const logName = `${screenName}${logType === 'event'? eventName : ''}`;

  return logName;
}
Enter fullscreen mode Exit fullscreen mode

With the introduction of logName, developers no longer have to care about logId. You don't have to copy and paste logId into code every time, and communication costs are naturally reduced. There is no knowledge that developers need to know to log, so the existing inconvenience is gone. Let me compare it with the existing code. You can see that the code that passed logId as a parameter has disappeared as follows.

function RegisterCardPage() {
  // ...
  // (removed)
  // const REGISTER_CARD_SCREEN_LOG_ID = 123; 
  // const REGISTER_CARD_CLICK_LOG_ID = 456;
  // const REGISTER_CARD_POPUP_LOG_ID = 789;

  useEffect(() => {
    logger.screen({
      // logId: REGISTER_CARD_SCREEN_LOG_ID, (removed)
      params: { title: PAGE_TITLE }
    });
  },[])

  return (
    <>
      // ...
      <Button onClick={async () => {
        try {
          logger.click({ 
            // logId: REGISTER_CARD_CLICK_LOG_ID, (removed)
            params: { title: PAGE_TITLE, button: 'next' }
          });
          await registerCard(cardInfo);
          // ...    
        } catch (error) {
          logger.popup({ 
            // logId: REGISTER_CARD_POPUP_LOG_ID, (removed)
            type: 'toast', 
            params: { title: PAGE_TITLE, message: error.message }
          });
          toast.open(error.message);
        }
      }}>
        next
      </Button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

But there are still some inconveniences left. Let me add more complex logic to the page where user registers card.

function RegisterCardPage() {
  // ...

  return (
    // ...
    <Button onClick={async () => {
      try {
        logger.click({ params: { title: PAGE_TITLE, button: 'Next' }});
        await validatecCrdNumber({ cardNumber });
        await validateCardOwner({ cardNumber, name });
        await registerCard({ cardNumber, ... });
        router.push('/identification');     
      } catch (error) {
        logger.popup({ 
          type: 'toast', 
          params: { title: PAGE_TITLE, message: error.message }
        });
        toast.open(error.message);
      }
    }}>
      Next
    </Button>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you look at the code, can you read the logic that works when you press the next button at a glance? Before you read the logic, don't you notice the code related to logging first?

In fact, the more complex the logic, or the more logging, the less readable it becomes. Even if you wanted to read only the business logic of the page, logging-related codes were mixed together. What developers care about is often business logic, not logging, but it was inconvenient to read business logic after encountering the logging-related code every time.

Managing Logging Declaratively

So I thought about isolating the logging logic from the business logic, and how I could write the code in a readable and declarative way. After thinking about how to write the code to a minimum, leaving only the concern of 'logging in the area', I created logging components like LogScreen and LogClick. The logging components are only responsible for logging.

// LogScreen component
export function LogScreen({ children, params }: Props) {
  const router = useRouter();
  const logger = useLogger();

  useEffect(() => {
    if (router.isReady) {
      logger.screen({ params });
    }
  }, [router.isReady]);

  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode
// LogClick component
export function LogClick({ children, params }: Props) {
  const child = Children.only(children);
  const logger = useLog();

  return cloneElement(child, {
    onClick: (...args: any[]) => {
      logger.click({ params });

      if (child.props && typeof child.props['onClick'] === 'function') {
        return child.props['onClick'](...args);
      }
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Developers have now changed to use LogScreen, LogClick components only for screens or buttons they want to log. Logging can now be handled more declaratively. We also achieved our initial goal of isolating logging-related logic from business logic. You just have to use the logging components without having to worry about implementations like, "At what point do I leave logic behind?"

function RegisterCardPage() {
  // ...

  return (
    <LogScreen params={{ title: PAGE_TITLE }}> // Added
    // ...
      <LogClick params={{ title: PAGE_TITLE, button: 'Next' }}> // Added
        <Button onClick={async () => {
          try {
            await validatecCrdNumber({ cardNumber });
            await validateCardOwner({ cardNumber, name });
            await registerCard({ cardNumber, ... });
            router.push('/identification');     
          } catch (error) {
            logger.popup({ 
              type: 'toast', 
              params: { title: PAGE_TITLE, message: error.message }
            });
            toast.open(error.message);
          }
        }}>
          Next
        </Button>
      </LogClick>
    </LogScreen>
  );
}
Enter fullscreen mode Exit fullscreen mode

Protecting code from logging

After all these logging improvements, the following pattern requirements have increased.

  • "I hope the title of the page on the screen log is also captured on the buttons at the bottom of the screen!"
  • "Please add all A log parameters to a specific area!"
  • "Please ensure that the identifier being used by this service is stamped on all log parameters!"

The code had to be more complicated to handle these requirements. Let's have look at it again with an example. It's a code written to leave the page title and the user's id in every log on the page where the card is registered.

function RegisterCardPage() {
  const { userId } = useUser();
  // ...

  return ( 
    <LogScreen params={{ title: PAGE_TITLE }}>
      // ...
      <RegisterCardForm
        logParameters={{ title: PAGE_TITME, userId }} 
        onSubmit={...}
      />
      <LogClick params={{ title: PAGE_TITLE, userId, button: '다음' }}>  
        <Button onClick={...}> 
          Next
        </Button>
      </LogClick>
    </LogScreen>
  );
}

function RegisterCardForm({ logParameters, onSubmit }) {
  return ( 
    <form onSubmit={onSubmit}>
      <CardNumberField />
      <LogClick params={{ ...logParameters, button: 'Initialize' }}>
        <Button>
          Initialize card number
        </Button>
      </LogClick>
      // ...
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we have no choice but to modify the props of sub-components to deliver the log parameters. We had to add a prop called logParameters to deliver the log parameters, and the deeper the depth of the component to be delivered, the worse the prop drilling became.

Also, the addition of props, which is far from the role of the component called logParameters, made the interface of the component awkward. The component called RegisterCardForm is just like the addition of prop called logParameters, which is not necessary for the role of form.

To solve this problem, we used react context. We created context that manages log parameters and made it possible for the provider to inject log parameters into specific areas. Each component could simply read and log the nearest LogParamsContext, and pass the log parameters to each component without transmitting props.

interface Props {
  children: ReactNode;
  params?: LogPayloadParameters;
}

const LogParamsContext = createContext<LogPayloadParameters | null>(null);

export function LogParamsProvider({ children, params }: Props) {
  return (
    <LogParamsContext.Provider
      value={params}
    >
      {children}
    </LogParamsContext.Provider>
  );
}

export function useLogParams() {
  return useContext(LogParamsContext);
}
Enter fullscreen mode Exit fullscreen mode
function useLogger() {  
  const parentParams = useLogParams();

  const log = (params) => {
    logClient.request({ params: { ...parentParams, ...params });
  }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

In addition, LogScreen made the LogScreen wrap the children with LogParamsProvider so that all logs taken under LogScreen can receive LogScreen's log parameters.

export function LogScreen({ children, params }: Props) {
  const router = useRouter();
  const logger = useLogger();

  useEffect(() => {
    if (router.isReady) {
      logger.screen({ params });
    }
  }, [router.isReady]);

  return <LogParamsProvider params={params}>{children}</LogParamsProvider>
}
Enter fullscreen mode Exit fullscreen mode

This eliminates the need to forward logParameters to props when log parameters are needed for sub-logging in certain areas. With only LogParamsProvider or LogScreen, you can inject multiple log parameters without having to create and deliver props or create your own global state as before. For logging, it has been changed to write code that harms the interface or to deliver unnecessary data to business logic.

function RegisterCardPage() {
  // ...
  return (
    <LogScreen params={{ title: PAGE_TITLE, userId }}>   
      // ...
      <RegisterCardForm onSubmit={...}/> 
      <LogClick params={{ button: 'next' }}>
        <Button onClick={...}>
          다음  
        </Button>   
      </LogClick>  
    </LogScreen> 
  );
}

function RegisterCardForm({ onSubmit }) { 
  return (  
    <form onSubmit={onSubmit}>   
      <CardNumberField />
      <LogClick params={{ button: 'Initialize' }}>
        <Button>
          Initialize card number
        </Button>
      </LogClick>
      // ... 
    </form>  
  );
}

Enter fullscreen mode Exit fullscreen mode

Hide logging

When looking at the logging code, there were a lot of similar patterns. For example, there were many patterns that were common in various services, such as wrapping the button with LogClick, wrapping the radio with LogClick, or adding logger.popup when toast came up. And this code was often used with components of TDS, our design system.

import { Button, useToaster } from '@tossteam/tds';

function RegisterCardPage() {
  const toast = useToaster();
  // ...

  return (
    // ...
    <LogClick params={{ button: 'next' }}>  
      <Button onClick={() => {  
        try { 
          // ...
        } catch (error) {
          logger.popup({ 
            type: 'toast',    
            params: { message: error.message }  
          }); 
          toast.open(error.message);
        } 
       }}>  
       next  
      </Button>
    </LogClick>
  );
}
Enter fullscreen mode Exit fullscreen mode

TDS is also a library that we're handling, and we use it on all products, so what if we create a TDS logging component that follows the TDS interface? The interface is like a regular TDS, but internally, it's a component that logs multiple events.

Based on this idea, we created a component that imposes additional log responsibility on the TDS component, a component with a single log decorator, which wraps around the TDS and logs it when a particular event occurs.

interface Props extends ComponentProps<typeof TdsButton> {
  logParams?: LogPayloadParameters;
}

export const Button = forwardRef(function Button({ logParams, ...props }: Props, ref: ForwardedRef<any>) {
  return (
    <LogClick params={{ ...logParams, button: getInnerTextOfReactNode(props.children) }} component="button">
      <TdsButton ref={ref} {...props} />
    </LogClick>
  );
});
Enter fullscreen mode Exit fullscreen mode
import { LogPayloadParameters } from '@tosspayments/log-core';
import { useLog } from '@tosspayments/log-react';
import { useToaster as useTDSToaster } from '@tossteam/tds-pc';
import { useMemo } from 'react';

interface LogParams {
  logParams?: LogPayloadParameters;
}

export function useToaster() {
  const toaster = useTDSToaster();
  const log = useLog();

  return useMemo(() => {
    return {
      open: (options: Parameters<typeof toaster.open>[0] & LogParams) => {
        log.popup({
          component: 'toast',
          params: { popupTitle: options.message, ...options?.logParams },
        });

        return toaster.open(options);
      },
      close: toaster.close,
    };
  }, [log, toaster]);
}
Enter fullscreen mode Exit fullscreen mode

Front-end developers can now log simply by modifying the import statement. You can make the component to log on its own by changing only @tosspayments/log-tds from @tossteam/tds. In addition, duplicate code have also disappeared because UI elements such as the ARIA label or button text in the image can be automatically logged without having to turn them over to log parameters every time.

import { Button, useToaster } from '@tosspayments/log-tds';

function RegisterCardPage() {
  // ...

  return (
  // ...
    <Button onClick={() => {
      try {    
        ... 
      } catch (error) {  
        toast.open(error.message);
      }
    }}>  
     next
    </Button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Summary

Let's take a look at the improved code again.

  • It no longer hands over the logId.
  • LogScreen allows you to create declarative logs.
  • You don't have to pass the page title to the log.
  • You don't have to log buttons or toast at all.

There is only one line of logging code, LogScreen, except for the import statement.

import { Button, useToaster } from '@tosspayments/log-tds';
import { LogScreen } from '@tosspayments/log-core';

function RegisterCardPage() {
  const toast = useToaster();

  return (
    <LogScreen title="Input the card number">
      // ...
      <Button onClick={async () => {
        try {
          await registerCard(cardInfo);
          // ... 
        } catch (error) {
          toast.open(error.message);  
        }
      }}>
        Next
      </Button>   
    </LogScreen>
  );
}
Enter fullscreen mode Exit fullscreen mode

Issues still not solved

This was a project that we started with the goal of "not caring about logging as much as possible," but with the current logging module structure, it's hard to say that we've accomplished that goal. For the following reasons.

  • Log parameters limited to a specific service must be added one by one by the service developer.
  • Service developers need to add screen logging using LogScreen every time.
  • You cannot use log TDS components when you customize them.
💖 💪 🙅 🚩
solleedata
Sol Lee

Posted on April 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related