Logging Tips for Frontend
Sol Lee
Posted on April 27, 2024
- This article is a translation from the original post: https://toss.tech/article/engineering-note-5
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>
</>
);
}
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>
);
}
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;
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;
}
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>
</>
);
}
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>
);
}
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}</>;
}
// 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);
}
},
});
}
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>
);
}
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>
);
}
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);
}
function useLogger() {
const parentParams = useLogParams();
const log = (params) => {
logClient.request({ params: { ...parentParams, ...params });
}
// ...
}
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>
}
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>
);
}
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>
);
}
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>
);
});
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]);
}
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>
);
}
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>
);
}
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.
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
November 30, 2024
November 30, 2024