Skip to content

Getting Started ​

This page will walk you through the basics of working with Frogger, the structure of logs, and how to create loggers for both client-side and server-side applications. Lets make your first log!

General Logging Advice

Frogger is not a console.log replacement. You add Frogger into places you know are causing trouble, and where you want to capture specific events in your application.

Log Levels ​

Frogger uses consola to ingest all logs, and therefore shares the same log levels:

Consola's Log Levels

bash
0: Fatal and Error
1: Warnings
2: Normal logs
3: Informational logs, success, fail, ready, start, ...
4: Debug logs
5: Trace logs
-999: Silent
+999: Verbose logs

*Frogger does not support consolas 'box' log level.

Creating logs ​

Before we can create a log, we need to create a logger. Frogger provides both client-side and server-side utilities you can use to create logger instances.

Quick logging with frogger (drop-in for console) ​

If you just want to log something without managing a logger instance, use the auto-imported frogger object. It's a drop-in replacement for console.* — the same method names (log, info, warn, error, debug, and the rest), available in both your app code and your server routes with no setup:

vue
<script setup lang="ts">
frogger.info('component mounted');            
frogger.error('checkout failed', err);        
frogger.log('cart total', total, { cartId }); 
</script>
ts
export default defineEventHandler(async () => {
    frogger.info('order created', { orderId }); 
});

Unlike console, frogger is variadic with structure: a trailing plain object becomes the structured ctx, any remaining arguments are joined into the msg, and an Error is lifted into ctx.error (with its stack). So frogger.error('checkout failed', err, { orderId }) produces msg: 'checkout failed â€Ļ' and ctx: { orderId, error: { â€Ļ } }.

One span per scope

frogger is backed by a single ambient logger — one per app on the client, one per request on the server (so server logs stay correlated with the incoming client trace). All frogger.* calls in that scope form one span chain. When you want an independent span or scoped context, create a fresh logger with useFrogger() / getFrogger() instead.

Per-request scoping on the server relies on serverModule.autoEventCapture (on by default).

Client-side Logging ​

In your app code, use the auto-imported useFrogger composable:

vue
<script setup lang="ts">

const logger = useFrogger(); 
logger.info('Hello, Client Frogger!'); 

</script>

Server-side Logging ​

In your server routes and utilities, use the auto-imported getFrogger function:

ts
export default defineEventHandler(async (event) => {

    const logger = getFrogger(); 
    logger.info('Hello, Server Frogger!'); 

});

Each logger instance provides methods for creating logs of any Log Level by its name, such as info, error, warn, etc. Check out the loggers section for more details.

Programmatic Logging ​

In some cases, you may want to dynamically generate logs of different levels. You can do this using the logLevel method, which supports a string corresponding to the type of consola log level

ts
logger.logLevel('error', 'This is an error at level 0');
logger.logLevel('fatal', 'This is a fatal log, also at level 0');

// Basic example
const currentLogLevel = ref('success');
logger.logLevel(currentLogLevel, 'This is a success log at level 3');

currentLogLevel.value = 'info'; // Change log level to info
logger.logLevel(currentLogLevel, 'This is an info log at level 2');

Log Anatomy ​

Frogger is opinionated about the structure of its logs to ensure consistency and to make it as easy to pick up and use as possible.

ts
export interface LoggerObject {
    time: number;
    lvl: number;
    type: LogType;
    msg: string;
    ctx: LogContext;
    tags?: string[];
    env: 'ssr' | 'csr' | 'client' | 'server';
    source?: {
        name: string;
        version: string;
    };
    trace: TraceContext;
}

The fields we are most interested in for now are msg and ctx.

- msg ​

Always the first argument you pass when creating a log. It is a string that should be a human-readable message describing the event. It should not contain any dynamic data. That's what the ctx field is for. This will also be printed to the console if you have console output enabled:

ts
logger.info('User logged in',  
    {
        userId: '12345',
        userName: 'john_doe',
        sessionId: 'abcde-12345-fghij-67890',
    }
);

And in your terminal or console you will see something like:

bash
â„šī¸ [info] User logged in
[2023-11-14T12:00:00.000Z]

- ctx ​

Always the second argument passed to the log method. It can be an object of any shape containing any additional context to the log you would like:

ts
logger.info('User logged in', 
    { 
        userId: '12345', 
        userName: 'john_doe', 
        sessionId: 'abcde-12345-fghij-67890', 
    } 
);
Click here to see the full log object
json
{
    time: 1700000000000,
    lvl: 2,
    type: 'info',
    msg: 'User logged in',
    ctx: {
        userId: '12345',
        userName: 'john_doe',
        sessionId: 'abcde-12345-fghij-67890',
    },
    tags: [],
    env: 'csr',
    source: {
        name: 'my-nuxt-app',
        version: '1.0.0',
    },
    trace: {
        traceId: '123e4567-e89b-12d3-a456-426614174000',
        parentId: '123e4567-e89b-12d3-a456-426614174001',
        spanId: '123e4567-e89b-12d3-a456-426614174002',
        flags: []
    },
}

Well-known ctx keys ​

Frogger keeps LoggerObject deliberately lean — it does not promote session, user, route or feature to first-class fields. Instead, put them in ctx under these exact keys. Downstream collectors (e.g. nuxt-observe) read them back out by the same names, so this is a stable contract:

Meaningctx key
Session idctx.session
User (id or object)ctx.user
Route / pathctx.route
Feature / areactx.feature
ts
logger.info('checkout viewed', {
    route: '/checkout',
    feature: 'checkout',
    session: sessionId,
    user: userId,
})

There's nothing special about these at runtime — they're ordinary context — but using the canonical names means a collector can index and filter by them without per-app configuration.

Loggers ​

Both client and server loggers implement the IFroggerLogger interface keeping your code consistent on front and back end:

ts
export interface IFroggerLogger {
    error(msg: string, ctx?: Object): void;
    fatal(msg: string, ctx?: Object): void;
    warn(msg: string, ctx?: Object): void;
    log(msg: string, ctx?: Object): void;
    info(msg: string, ctx?: Object): void;
    success(msg: string, ctx?: Object): void;
    fail(msg: string, ctx?: Object): void;
    ready(msg: string, ctx?: Object): void;
    start(msg: string, ctx?: Object): void;
    debug(msg: string, ctx?: Object): void;
    trace(msg: string, ctx?: Object): void;
    silent(msg: string, ctx?: Object): void;
    verbose(msg: string, ctx?: Object): void;
    logLevel(level: LogType, msg: string, ctx?: Object): void;

    addReporter(reporter: IFroggerReporter): void;
    removeReporter(reporter: IFroggerReporter): void;
    getReporters(): readonly IFroggerReporter[];
    clearReporters(): void;

    addContext(ctx: Object, options?: { overwrite?: boolean }): void;
    setContext(ctx: Object): void;
    clearContext(): void;

    child(options: FroggerOptions): IFroggerLogger;
    reactiveChild(options: FroggerOptions): IFroggerLogger;

    getHeaders(customVendor?: string): Record<string, string>;
    reset(): void;
}

Configuring loggers ​

Loggers can be configured on a per-instance basis by passing in options in its constructor:

ts
export interface FroggerOptions {
    context?: LogContext;
    scrub?: {
        maxDepth?: number;
        deepScrub?: boolean;
        preserveTypes?: boolean;
        rules?: ScrubRule[];
    } | boolean;
    consoleOutput?: boolean;
}

Here are some usage examples:

ts
// Client-side
const logger = useFrogger({
    consoleOutput: false,
});

// Server-side
const logger = getFrogger({
    scrub: {
        maxDepth: 3,
        deepScrub: true,
    },
});

TIP

consoleOutput here only affects this one logger. To silence the console across a whole app, so that a production browser build prints nothing to devtools while still shipping every log to your transports, use the consoleOutput module option instead.

Adding Context ​

This is an example of adding context to your loggers. This will be appended to every log created by this logger:

ts
const logger = useFrogger({
    context: {
        favouriteColor: 'blue',
    },
});

logger.log('User logged in', {
    userId: '12345',
    userName: 'john_doe',
});

// This will log:
// {
//     ...,
//     msg: 'User logged in',
//     ctx: {
//         favouriteColor: 'blue', // From the logger context
//         userId: '12345',
//         userName: 'john_doe',
//     },
//     ...

If you'd like to add context to an existing logger, you can use the addContext method. This deep-merges the incoming context into the existing one:

ts
const logger = useFrogger();

logger.addContext({
    numberOfCats: 3,
    location: 'London',
});

When the incoming context shares a key with the existing context, the incoming value wins by default (last-write-wins). Re-stamping a key updates it, so long-lived context like a route or user never freezes on its first value:

ts
logger.addContext({ route: '/login' });
logger.addContext({ route: '/dashboard' });
// route is now '/dashboard'

Pass { overwrite: false } when you instead want to keep existing values and only fill in keys that aren't already set:

ts
logger.addContext({ tenant: 'acme' });
logger.addContext({ tenant: 'other', region: 'eu' }, { overwrite: false });
// context is { tenant: 'acme', region: 'eu' }

Additional Methods ​

You can replace a loggers context entirely with the setContext method:

ts
setContext(ctx: Object): void;

Or you can remove all context with the clearContext method:

ts
clearContext(): void;

Child Loggers ​

You can create child loggers that inherit the context of their parent while still containing their own internal context:

ts
const parentLogger = useFrogger({
    context: {
        userId: '12345', 
    },
});

const childLogger = parentLogger.child({
    context: {
        sessionId: 'abcde-12345-fghij-67890',
    },
});

childLogger.info('User logged in');

This will create a log with the context:

ts
ctx: { 
    userId: '12345', // From the parent  
    sessionId: 'abcde-12345-fghij-67890' // From the child
}

Reactive Child Loggers ​

In some circumstances, you may want multiple loggers to share the same reactive context, so that changes made to a parent will be reflected in all children. You can do this by using the reactiveChild method:

ts
const parentLogger = useFrogger({
    context: {
        userId: '12345',
    },
});
const childLogger = parentLogger.reactiveChild({
    context: {
        sessionId: 'abcde-12345-fghij-67890',
    },
});

childLogger.info('User logged in');

This will create a log with the same context as the previous example:

ts
ctx: {
    userId: '12345', // From the parent
    session: 'abcde-12345-fghij-67890',
}

However, say a user logged in with a different user ID and the parent logger had its context updated, all child loggers would automatically update their context to reflect this:

ts
parentLogger.setContext({
    userId: '67890', 
});

This will result in the child logger's context being updated to:

ts
ctx: {
    userId: '67890', // Updated from the parent
    sessionId: 'abcde-12345-fghij-67890',
}

Trace Context ​

Frogger supports the W3C Trace Context standard, which allows you to trace requests across distributed systems via tracestate and traceparent headers. Any Nuxt application that uses Frogger will automatically parse these headers:

http
traceparent: '00-70729f2d10910d20c8a0ba9d34d09912-79d6a5bfe9349090-01'
tracestate: 'frogger=2c615d534779d87b'

Adding Trace Context to Requests ​

Frogger will generate these headers for you, however you need to ensure that you pass the headers along with any requests you make to other services, or even to your own backend if you would like to follow requests from the client to the server:

ts
const logger = useFrogger();

const response = await $fetch('/api/some-endpoint', { 
    headers: logger.getHeaders(), 
});
View server-side example
ts
const logger = getFrogger();

const response = await $fetch('https://api.example.com/data', { 
    headers: logger.getHeaders(), 
});

In the above example, the getHeaders() method will generate two headers: traceparent and tracestate.

- traceparent ​

To follow a request from one service to another, Frogger generates a traceparent header that contains the trace ID, parent ID, and flags:

http
// In the format: {version}-{trace ID}-{parent ID}-{flags}
traceparent: '00-70729f2d10910d20c8a0ba9d34d09912-79d6a5bfe9349090-01'

- tracestate ​

The second header is tracestate, which carries vendor-specific trace information — useful to identify which systems the trace passed through. Frogger writes its data under the frogger token:

http
// In the format: {vendor}={trace data}
tracestate: 'frogger=79d6a5bfe9349090'

Each service prepends its own entry (systems using Frogger handle this automatically), so you can follow the request as it passes through multiple systems:

http
tracestate: 'frogger=79d6a5bfe9349090,my-other-service=12345-67890'

You can override the value Frogger writes by passing a vendor string to getHeaders():

ts
const logger = useFrogger();

const response = await $fetch('/api/some-endpoint', {
    headers: logger.getHeaders('my-custom-vendor'), 
});

// This will generate the following tracestate header:
// tracestate: 'frogger=my-custom-vendor'

Client to Server ​

We have seen how to add trace context to outgoing request, but what about incoming request? Frogger handles this for use by taking advantage of Nuxt's experimental asyncData feature, which allows Frogger's loggers to automatically grab the incoming request event without needing to pass it around manually.

ts
export default defineEventHandler(async (event) => {
    const logger = getFrogger(); // getFrogger will capture the incoming event
    logger.info('Incoming request');
});

Don't want to use experimental features? No problem! You can disable this at any time with the autoEventCapture option in your module options:

ts
export default defineNuxtConfig({
    modules: [
        'frogger'
    ],
    frogger: {
        serverModule: {
            autoEventCapture: false, 
        }
    }
});

However, you will now need to pass the event to getFrogger manually for each server-side logger you create:

ts
export default defineEventHandler(async (event) => {
    const logger = getFrogger(event); // pass in the request
    logger.info('Incoming request');
});

When you create a logger using getFrogger, it will automatically parse the incoming traceparent and tracestate headers, allowing you to continue tracing requests between your front and back end.

This means, the last log created on the client will be the parent of the first log created on the server. And vice versa.

Application Tracing ​

Frogger takes tracing a step further by automatically generating trace and span IDs for every log made, linking logs together that are created from the same logger instance. This means you can trace events throughout your own applications, as well as across distributed systems.

This is why you should NOT create one logger instance that is shared across your entire application. Instead, create loggers via the useFrogger and getFrogger as you need them.

TIP

Creating a logger is cheap, so you can make new loggers for each component, route, or utility function.

Nested Spans ​

Sometimes a flat chain of logs isn't enough — you want everything that happens inside one logical operation grouped under a name. frogger.span() runs a function inside a named span; every log made while it runs, even from helpers that never receive a logger, automatically nests under it:

ts
await frogger.span('processOrder', async () => {
    frogger.info('validating order');  // nests under processOrder
    await chargeCard();                // its frogger.* logs nest too
});

The span name is recorded on each log's context as ctx.span, and each log's parentId chains under the enclosing span — so the whole operation reads as one tree in your log output.

Spans nest: a span() opened inside another becomes its child. Calls to getFrogger() inside a span continue the tree too, instead of starting a new branch from the request root.

INFO

On the server, spans are backed by AsyncLocalStorage, so two concurrent requests never mix their trees. In the browser they are best-effort: sequential await chains are always correct, but two spans awaiting concurrently can observe each other.

Prefer to hold on to the logger instead? frogger.startSpan() returns a named child logger parented under the current span, which you can pass around like any logger:

ts
const audit = frogger.startSpan('audit');
audit.info('order recorded');

Next steps ​

Now that you can make logs, explore what Frogger does with them: