Skip to main content

Change log levels without restarting.

Get exactly the logging you need, only when you need it, with Prefab dynamic logging.

Making Front End Logging Useful

· 8 min read
Jeffrey Chupp
Jeffrey Chupp
Prefab Founding Engineer. Three-time dad. Polyglot. I am a pleaser. He/him.
Lost in an avalanche of needles

Front-end logging can feel like finding a needle in an avalanche of haystacks.

Since the browser is the bridge between user interaction and the back-end, front-end logs have the potential to be a treasure trove of insights.

Datadog and others offer browser log collections but everyone I’ve talked to that has tried this has turned it off. Why? Way too expensive.

How can we get meaningful signal from our front-end logging? Three steps:

  1. Transports: We need to put some logic between the log statement and the aggregator. Learn how to direct your logs to the right places and why it matters.
  2. Context: We need to give that logic context, so it can decide what it shouldLog. Crafting actionable error messages and adding the right context will make your logs meaningful.
  3. Targeting: We need some rule evaluation for Laser targeting. We’ll go over how to selectively capture logs for troubleshooting without overwhelming your system.

Transports: Sending the right logs to the right places

Step one in front-end logging is figuring out how to get access to the content. It doesn't matter how good your logging is if it only exists in your end-user's browser.

You'll likely want to sign up with a SaaS for log aggregation rather than rolling your own. We'll use Datadog as our example.

After following the installation steps for @datadog/browser-logs, you're able to use datadogLogs.logger.info (or error, or debug, etc.) wherever you'd normally use console.log.

🤔 We probably don't want to replace console.log entirely, right? For local development, it'd sure be nice to see the logs in my browser rather than going to the Datadog UI.

This is where we start thinking in "transports." There's no reason all our logging should go to the same places. Instead, we can have a logger with intelligent routing. This opens up possibilities like

  • Everything in local dev stays local in the console. Nothing is suppressed.
  • Production shows only ERROR messages in the console. Less severe messages don't show up.
  • Production sends anything INFO or more severe to Datadog.

Here's a rough sketch of what this might look like:

const ERROR = 0;
const INFO = 1;
const DEBUG = 2;

const severityLookup = {
[ERROR]: "error",
[INFO]: "info",
[DEBUG]: "debug",
};

const datadogTransport = (severity, message) =>
datadog.logger[severity](message);

const consoleTransport = (severity, message) => console[severity](message);

export const logger = (environment) => {
const transports = [];

if (environment === "production") {
transports.push([datadogTransport, { minSeverity: INFO }]);
transports.push([consoleTransport, { minSeverity: ERROR }]);
} else {
transports.push([consoleTransport]);
}

const report = (severity, message) => {
transports.forEach(([transport, options]) => {
const { minSeverity } = options || {};
if (minSeverity === undefined || severity <= minSeverity) {
transport(severityLookup[severity], message);
}
});
};

return {
debug: (message) => {
report(DEBUG, message);
},
info: (message) => {
report(INFO, message);
},
error: (message) => {
report(ERROR, message);
},
};
};

To easily compare levels, we treat them as numbers. How many log levels you want and what they should be is a subject of some debate

Context is King

There's an art to crafting a good error message.

Without further details, "Something went wrong during checkout" is inactionable and likely to frustrate the developer on the receiving end.

"[checkout]: Charge has an invalid parameter" is a better message but still not yet actionable. Who did this happen to? What were they trying to buy?

Our logs are most actionable when we couple a well-crafted error message with metadata about the state of the user and app. Datadog and other offerings support this metadata out-of-the-box.

Consider the following

logger.error("[checkout]: charge has an invalid parameter", {
customerId: "cus_9s6XKzkNRiz8i3",
cart: { plan: "Pro", credits: desiredCredits },
});

This logs something like

ERROR: [checkout]: Charge has an invalid parameter | customerId=cus_9s6XKzkNRiz8i3 cart.plan="Pro" cart.credits=-10

They tried to buy the Pro plan and -10 credits? -10 looks weird! That's a thread to pull on.

Adding context to our example logger implementation is easy enough. We can just add an extra context variable (already supported by @datadog/browser-logs) throughout.

Updated code

const ERROR = 0;
const INFO = 1;
const DEBUG = 2;

const severityLookup = {
[ERROR]: "error",
[INFO]: "info",
[DEBUG]: "debug",
};

const datadogTransport = (severity, message, context) =>
datadog.logger[severity](message, context);

const consoleTransport = (severity, message, context) =>
console[severity](message, context);

export const logger = (environment) => {
const transports = [];

if (environment === "production") {
transports.push([datadogTransport, { minSeverity: INFO }]);
transports.push([consoleTransport, { minSeverity: ERROR }]);
} else {
transports.push([consoleTransport]);
}

const report = (severity, message, context) => {
transports.forEach(([transport, options]) => {
const { minSeverity } = options || {};
if (minSeverity === undefined || severity <= minSeverity) {
transport(severityLookup[severity], message, context);
}
});
};

return {
debug: (message, context) => {
report(DEBUG, message, context);
},
info: (message, context) => {
report(INFO, message, context);
},
error: (message, context) => {
report(ERROR, message, context);
},
};
};

We ship our logger and everything is working great. Developers are adding more logging and it is easier than ever to track down issues in local dev. But since we're being strategic about what gets shipped to Datadog, we're not paying for those console.debug lines.

Laser Targeting

We start getting reports that the activity feed is broken for the user with trackingId=abc-123. How can we use our approach to access the DEBUG-level logs for this user without recording those same DEBUG-level logs for every user?

You could tweak the report innards to consider the context:

const TRACKING_IDS_TO_ALWAYS_LOG = ["abc-123"];

const report = (severity, message, context) => {
transports.forEach(([transport, options]) => {
const { minSeverity } = options || {};
if (
minSeverity === undefined ||
severity <= minSeverity ||
TRACKING_IDS_TO_ALWAYS_LOG.includes(context.trackingId)
) {
transport(severityLookup[severity], message, context);
}
});
};

This gives us a nice mechanism for allow-listing certain tracking ids for verbose logging.

logger.debug("test", { trackingId: user.trackingId });

// Examples
logger.debug("test", { trackingId: "abc-123" }); // This is sent to Datadog (trackingId matches)
logger.debug("test", { trackingId: "xyz-999" }); // This is not sent

A hard-coded array of tracking ids means we'll need to PR, merge, deploy, etc. to get this change out, but this is still a powerful approach. We'll get all the details we need to fix the user's problem. Once fixed, we can remove their tracking id from the array and do the PR/merge/deploy dance once more.

Dynamic Targeting with Prefab

That all works great. You can call it a day and move on, confident and happy that enabled yourself and other devs to log better than ever.

But let me offer you a superpower: Sign up for Prefab and install @prefab-cloud/prefab-cloud-js (or the react flavor). Now you can target based on any context data to just the right logs to just the right places -- without having to change any code after the initial setup.

Here's the changes needed to start using prefab.shouldLog

import { prefab, Context } from "@prefab-cloud/prefab-cloud-js";

// Set up prefab with the context of the current user
const options = {
apiKey: "YOUR-API-KEY-GOES-HERE",
context: new Context({
user: { trackingId: "abc-123", email: "test@example.com" },
device: { mobile: true },
}),
};
prefab.init(options);

// ...

export const logger = (environment, loggerName) => {
// ...
const report = (severity, message, context) => {
transports.forEach(([transport, options]) => {
const { defaultLevel } = options || {};

// Use prefab to check if we should log based on the user context
if (
prefab.shouldLog({
loggerName,
desiredLevel: severityLookup[severity],
defaultLevel: severityLookup[defaultLevel] ?? "debug",
})
) {
transport(severityLookup[severity], message, context);
}
});
};
// ...

Now we can use the tools in the Prefab UI to target trackingId abc-123 and add and remove users without shipping code changes. Because you can set up custom rules to target whatever you provide in your context, we can even do things like target specific users on specific devices.

Full updated code

import { prefab, Context } from "@prefab-cloud/prefab-cloud-js";

// Set up prefab with the context of the current user
const options = {
apiKey: "YOUR-API-KEY-GOES-HERE",
context: new Context({
user: { trackingId: "abc-123", email: "test@example.com" },
device: { mobile: true },
}),
};
prefab.init(options);

const ERROR = 0;
const INFO = 1;
const DEBUG = 2;

const severityLookup = {
[ERROR]: "error",
[INFO]: "info",
[DEBUG]: "debug",
};

const datadogTransport = (severity, message, context) =>
datadog.logger[severity](message, context);

const consoleTransport = (severity, message, context) =>
console[severity](message, context);

export const logger = (environment, loggerName) => {
const transports = [];

if (environment === "production") {
transports.push([datadogTransport, { defaultLevel: INFO }]);
transports.push([consoleTransport, { defaultLevel: ERROR }]);
} else {
transports.push([consoleTransport]);
}

const report = (severity, message, context) => {
transports.forEach(([transport, options]) => {
const { defaultLevel } = options || {};

// Use prefab to check if we should log based on the user context
if (
prefab.shouldLog({
loggerName,
desiredLevel: severityLookup[severity],
defaultLevel: severityLookup[defaultLevel] ?? "debug",
})
) {
transport(severityLookup[severity], message, context);
}
});
};

return {
debug: (message, context) => {
report(DEBUG, message, context);
},
info: (message, context) => {
report(INFO, message, context);
},
error: (message, context) => {
report(ERROR, message, context);
},
};
};

Wait, you forget to tell me which logging library to install!

You might not need one. I'd hold off picking until I knew more about why I wanted one.

IMHO, there isn't a clear winner. Since the major players (e.g. pino and winston) support you providing your own transports, you can pick whichever you prefer and always change your mind later.

Like what you read? Change log levels on the fly with Prefab. Start now for free.
Learn More