TinyKit Pro Docs
Technical

Logging System

TinyKit Pro includes a comprehensive logging system with environment-based log levels, structured output, and performance timing capabilities. The logging sy...

TinyKit Pro includes a comprehensive logging system with environment-based log levels, structured output, and performance timing capabilities. The logging system uses emoji prefixes and console methods that integrate seamlessly with Convex's built-in logging infrastructure.

đŸŽ¯ Purpose

The logging system provides:

  • Environment-Aware Logging: Automatic log level adjustment based on deployment environment
  • Structured Output: Consistent formatting with emoji prefixes for easy visual scanning
  • Performance Timing: Built-in timing capabilities for performance monitoring
  • Runtime Control: Optional runtime log level override for debugging
  • Development Workflow: Verbose logging in development, minimal logging in production
  • Enforced Best Practices: ESLint rules prevent console.* usage and enforce logger utility usage

đŸ—ī¸ Architecture

Log Levels

TinyKit Pro supports four log levels in order of severity:

LevelEmojiPurposeProductionDevelopment
DEBUG🔍Detailed diagnostic informationHiddenVisible
INFOâ„šī¸General informational messagesHiddenVisible
WARNâš ī¸Warning messages for potential issuesHiddenVisible
ERROR❌Error messages for failuresVisibleVisible

Log Level Hierarchy: Each level includes all higher-severity levels. For example, INFO includes WARN and ERROR but excludes DEBUG.

Environment-Based Configuration

The logger automatically determines the appropriate log level based on environment:

CONVEX_ENV="development"  → DEBUG   (all logs visible)
CONVEX_ENV="preview"      → INFO    (info, warn, error)
CONVEX_ENV="staging"      → INFO    (info, warn, error)
CONVEX_ENV="production"   → ERROR   (only errors visible)

Override: Set LOG_LEVEL environment variable to explicitly control log level:

npx convex env set LOG_LEVEL "DEBUG"
npx convex env set LOG_LEVEL "INFO"
npx convex env set LOG_LEVEL "WARN"
npx convex env set LOG_LEVEL "ERROR"

🚀 Usage

Basic Logging

import { logger } from "../../lib/logger";
import { mutation } from "../../lib/triggers/triggers";
import { v } from "convex/values";

export const createPost = mutation({
  args: { title: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    // Debug logging (only in development)
    logger.debug("Creating post with args:", args);

    // Info logging (development and staging)
    logger.info("Post creation started", { title: args.title });

    try {
      const postId = await ctx.db.insert("posts", args);

      // Success info log
      logger.info("Post created successfully", { postId });

      return { postId };
    } catch (error) {
      // Error logging (always visible)
      logger.error("Failed to create post", error);
      throw error;
    }
  },
});

Structured Logging

Always include context objects for better debugging:

// ❌ Poor: String-only logging
logger.info("Processing user ID 123");

// ✅ Good: Structured logging with context
logger.info("Processing user", { userId: "123", operation: "update" });

// ✅ Excellent: Rich context for debugging
logger.info("Processing subscription update", {
  userId: user._id,
  subscriptionId: subscription._id,
  previousStatus: subscription.status,
  newStatus: "active",
  timestamp: Date.now(),
});

Webhook and External API Logging

export const handleStripeWebhook = internalAction({
  args: { signature: v.string(), payload: v.string() },
  handler: async ({ runAction }, { signature, payload }) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {});

    try {
      const event = await stripe.webhooks.constructEventAsync(
        payload,
        signature,
        process.env.STRIPE_WEBHOOKS_SECRET!,
      );

      // Log incoming webhook
      logger.info("Processing Stripe webhook", {
        eventType: event.type,
        eventId: event.id,
      });

      // Process event...
      await processEvent(event);

      logger.info("Webhook processed successfully", {
        eventType: event.type,
      });

      return { success: true };
    } catch (error) {
      logger.error("Webhook processing failed", {
        error,
        signature: signature.substring(0, 20) + "...", // Truncate for security
      });

      return { success: false };
    }
  },
});

Performance Timing

Use logger.time() and logger.timeEnd() for performance monitoring:

export const expensiveOperation = mutation({
  args: { dataSize: v.number() },
  handler: async (ctx, args) => {
    const operationId = `operation-${Date.now()}`;

    // Start timing
    logger.time(operationId);

    try {
      // Perform expensive operation
      const results = await processLargeDataset(ctx, args.dataSize);

      // End timing (logs elapsed time)
      logger.timeEnd(operationId);

      logger.info("Operation completed", {
        dataSize: args.dataSize,
        resultCount: results.length,
      });

      return results;
    } catch (error) {
      logger.timeEnd(operationId); // Still log timing on error
      logger.error("Operation failed", error);
      throw error;
    }
  },
});

Output Example:

â„šī¸  [INFO] Operation started
âąī¸  [TIMER] operation-1704067200000: 1234ms
â„šī¸  [INFO] Operation completed { dataSize: 1000, resultCount: 150 }

Runtime Log Level Control

For debugging specific operations, allow runtime log level override:

import { createLogger, logLevel } from "../../lib/logger";
import { mutation } from "../../lib/triggers/triggers";
import { v } from "convex/values";

export const debugOperation = mutation({
  args: {
    data: v.string(),
    // Optional log level override for debugging
    logLevel: v.optional(logLevel),
  },
  handler: async (ctx, args) => {
    // Create logger with optional override
    const logger = createLogger(args.logLevel);

    logger.debug("Operation started with debug logging enabled");
    logger.info("Processing data", { dataLength: args.data.length });

    // ... operation logic

    logger.debug("Operation completed");
  },
});

Usage from frontend:

// Normal operation (uses environment log level)
await debugOperation({ data: "test" });

// Debug-enabled operation (force DEBUG level)
await debugOperation({ data: "test", logLevel: "DEBUG" });

âš™ī¸ Advanced Patterns

Multi-Step Operation Logging

export const processWorkflow = mutation({
  args: { workflowId: v.id("workflows") },
  handler: async (ctx, args) => {
    logger.info("Workflow processing started", { workflowId: args.workflowId });

    // Step 1: Validation
    logger.debug("Step 1: Validating workflow");
    const workflow = await ctx.db.get(args.workflowId);
    if (!workflow) {
      logger.error("Workflow not found", { workflowId: args.workflowId });
      throw new Error("Workflow not found");
    }
    logger.debug("Step 1: Validation complete");

    // Step 2: Processing
    logger.debug("Step 2: Processing tasks");
    const tasks = await ctx.db
      .query("tasks")
      .withIndex("by_workflowId", (q) => q.eq("workflowId", args.workflowId))
      .collect();
    logger.info("Tasks retrieved", { taskCount: tasks.length });

    // Step 3: Completion
    logger.debug("Step 3: Marking workflow complete");
    await ctx.db.patch(args.workflowId, { status: "completed" });

    logger.info("Workflow processing completed", {
      workflowId: args.workflowId,
      taskCount: tasks.length,
    });

    return { success: true, taskCount: tasks.length };
  },
});

Error Context Logging

export const updateUserProfile = mutation({
  args: { userId: v.string(), name: v.string() },
  handler: async (ctx, args) => {
    try {
      logger.info("Updating user profile", {
        userId: args.userId,
        newName: args.name,
      });

      const user = await ctx.db.get(args.userId);
      if (!user) {
        logger.warn("User not found for update", {
          userId: args.userId,
          attempted: "profile update",
        });
        throw new Error("User not found");
      }

      await ctx.db.patch(args.userId, { name: args.name });

      logger.info("Profile updated successfully", {
        userId: args.userId,
        previousName: user.name,
        newName: args.name,
      });
    } catch (error) {
      logger.error("Profile update failed", {
        userId: args.userId,
        error: error instanceof Error ? error.message : String(error),
        stack: error instanceof Error ? error.stack : undefined,
      });
      throw error;
    }
  },
});

Conditional Debug Logging

export const searchItems = query({
  args: { query: v.string() },
  handler: async (ctx, args) => {
    const startTime = Date.now();

    logger.debug("Search query received", {
      query: args.query,
      queryLength: args.query.length,
    });

    const results = await ctx.db
      .query("items")
      .withSearchIndex("search_name", (q) => q.search("name", args.query))
      .take(10);

    const duration = Date.now() - startTime;

    // Only log slow queries in production (warn threshold: 500ms)
    if (duration > 500) {
      logger.warn("Slow search query detected", {
        query: args.query,
        duration,
        resultCount: results.length,
      });
    } else {
      logger.debug("Search completed", {
        duration,
        resultCount: results.length,
      });
    }

    return results;
  },
});

Background Job Logging

import { internalMutation } from "../../_generated/server";
import { logger } from "../../lib/logger";

export const processScheduledJob = internalMutation({
  args: {},
  handler: async (ctx) => {
    logger.info("Scheduled job started", { timestamp: Date.now() });

    try {
      // Get items to process
      const items = await ctx.db
        .query("pendingItems")
        .withIndex("by_status", (q) => q.eq("status", "pending"))
        .collect();

      logger.info("Items to process", { count: items.length });

      let successCount = 0;
      let errorCount = 0;

      for (const item of items) {
        try {
          await processItem(ctx, item);
          successCount++;
        } catch (error) {
          errorCount++;
          logger.error("Item processing failed", {
            itemId: item._id,
            error,
          });
        }
      }

      logger.info("Scheduled job completed", {
        total: items.length,
        success: successCount,
        errors: errorCount,
      });
    } catch (error) {
      logger.error("Scheduled job failed", error);
      throw error;
    }
  },
});

📊 Log Output Format

Console Output

# DEBUG level (development only)
🔍 [DEBUG] Operation started { userId: "abc123", operation: "update" }

# INFO level (development and staging)
â„šī¸  [INFO] Processing user { userId: "abc123", email: "user@example.com" }

# WARN level (all environments except production)
âš ī¸  [WARN] Slow query detected { query: "search term", duration: 523 }

# ERROR level (all environments)
❌ [ERROR] Database operation failed { error: "Connection timeout" }

# TIMER output (development and staging)
âąī¸  [TIMER] expensive-operation: 1234ms

Structured Data

Always use objects for context:

// ✅ Good: Structured context
logger.info("User authenticated", {
  userId: user._id,
  email: user.email,
  provider: "github",
  timestamp: Date.now(),
});

// ❌ Bad: String interpolation
logger.info(`User ${user._id} authenticated via ${provider}`);

Benefits of structured logging:

  • Easy to search in Convex logs dashboard
  • Machine-readable for log aggregation tools
  • Consistent format across the application
  • Better debugging with full context

🔒 ESLint Enforcement

TinyKit Pro enforces consistent logging practices through ESLint rules to prevent common mistakes and ensure code quality.

Enforced Rules

1. No Direct Console Usage

// ❌ ESLint Error: no-console
console.log("User created");
console.info("Processing...");
console.warn("Warning message");
console.error("Error occurred");

// ✅ Correct: Use logger utility
logger.info("User created");
logger.debug("Processing...");
logger.warn("Warning message");
logger.error("Error occurred");

Rule: "no-console": "error"

Applies to: All files except:

  • convex/lib/logger.ts - Logger implementation itself
  • src/lib/logger.ts - Frontend logger (if needed)
  • scripts/**/*.ts - Development scripts
  • convex/betterAuth/**/*.ts - BetterAuth component files

2. No console.error in Catch Blocks

// ❌ ESLint Error: Pollutes browser console and fails PageSpeed metrics
try {
  await riskyOperation();
} catch (error) {
  console.error(error); // ESLint error: no-restricted-syntax
}

// ✅ Correct: Use logger.error
try {
  await riskyOperation();
} catch (error) {
  logger.error("Operation failed", { error, operation: "riskyOperation" });
  throw error; // Re-throw if needed
}

// ✅ Also acceptable: Silent fail with comment
try {
  await nonCriticalOperation();
} catch (error) {
  // Silently fail - non-critical operation, user experience unaffected
  // Error logged to monitoring service via error boundary
}

Rule: Custom no-restricted-syntax for console.error in catch blocks

Reason: console.error() in catch blocks:

  • Pollutes browser console with non-actionable errors
  • Fails Google PageSpeed metrics
  • Clutters production logs
  • Makes debugging harder by mixing expected and unexpected errors

3. No Empty Catch Blocks

// ❌ ESLint Error: Empty catch blocks swallow errors
try {
  await operation();
} catch (error) {
  // Empty - no handling
}

// ✅ Correct: Log the error or add explanatory comment
try {
  await operation();
} catch (error) {
  logger.error("Operation failed", { error });
}

// ✅ Also acceptable: Silent fail with clear justification
try {
  await nonCriticalOperation();
} catch (error) {
  // Intentionally silent - non-critical background operation
  // Logged via global error boundary for monitoring
}

Rule: Custom no-restricted-syntax for empty catch blocks

Exemptions

Certain directories are exempt from console rules:

  1. Logger implementations: convex/lib/logger.ts, src/lib/logger.ts
  2. Scripts: scripts/**/*.ts (development utilities)
  3. BetterAuth: convex/betterAuth/**/*.ts (third-party component)

Linting Commands

# Check for logging violations
bun lint

# Auto-fix some issues (won't fix console → logger)
bun lint:fix

# Run before committing (enforced by pre-commit hook)
bun typecheck && bun lint

🔧 Configuration

Environment Setup

Set the deployment environment:

# Development (DEBUG level)
npx convex env set CONVEX_ENV "development"

# Staging (INFO level)
npx convex env set CONVEX_ENV "staging"

# Production (ERROR level)
npx convex env set CONVEX_ENV "production"

Override Log Level

Force a specific log level regardless of environment:

# Force DEBUG in production (temporary debugging)
npx convex env set LOG_LEVEL "DEBUG"

# Remove override (use environment-based level)
npx convex env remove LOG_LEVEL

Viewing Logs

# Stream live logs from Convex
npx convex logs --tail

# Filter logs by keyword
npx convex logs --tail | grep "ERROR"
npx convex logs --tail | grep "user\|auth"

# View logs in Convex Dashboard
npx convex dashboard

đŸŽ¯ Best Practices

1. Choose the Right Log Level

// DEBUG: Detailed diagnostic information
logger.debug("Function arguments", { args });
logger.debug("Intermediate calculation result", { value: intermediateResult });

// INFO: General operational messages
logger.info("User created", { userId, email });
logger.info("Webhook received", { eventType, eventId });

// WARN: Unexpected but recoverable conditions
logger.warn("Deprecated API used", { endpoint, userId });
logger.warn("Rate limit approaching", { userId, remaining: 10 });

// ERROR: Failures requiring attention
logger.error("Database write failed", { error, userId });
logger.error("External API unavailable", { service: "Stripe", error });

2. Include Relevant Context

// ❌ Insufficient context
logger.error("Update failed");

// ✅ Rich context for debugging
logger.error("User profile update failed", {
  userId: user._id,
  email: user.email,
  attemptedChanges: { name: newName },
  error: error.message,
  timestamp: Date.now(),
});

3. Avoid Logging Sensitive Data

// ❌ NEVER log sensitive data
logger.info("User signed in", {
  password: args.password, // NEVER
  apiKey: process.env.API_KEY, // NEVER
  creditCard: user.creditCard, // NEVER
});

// ✅ Log safely with redaction
logger.info("User signed in", {
  userId: user._id,
  email: user.email.replace(/@.*/, "@***"), // Partially redacted
  provider: "password",
  hasValidSession: true,
});

4. Use Performance Timing for Expensive Operations

export const generateReport = mutation({
  handler: async (ctx) => {
    logger.time("generateReport");

    // Expensive operation
    const data = await fetchLargeDataset(ctx);
    const processed = await processData(data);
    const report = await createReport(processed);

    logger.timeEnd("generateReport");

    return report;
  },
});

5. Log State Transitions

export const updateSubscription = mutation({
  args: { subscriptionId: v.string(), newStatus: v.string() },
  handler: async (ctx, args) => {
    const subscription = await ctx.db.get(args.subscriptionId);

    logger.info("Subscription status change", {
      subscriptionId: args.subscriptionId,
      previousStatus: subscription?.status,
      newStatus: args.newStatus,
      userId: subscription?.userId,
    });

    await ctx.db.patch(args.subscriptionId, { status: args.newStatus });
  },
});

🚨 Common Mistakes

// ❌ DON'T: Use console.log directly (ESLint will prevent this)
console.log("User created"); // ESLint error: no-console

// ✅ DO: Use logger methods
logger.info("User created", { userId });

// ❌ DON'T: Log in loops without aggregation
for (const item of items) {
  logger.info("Processing item", { itemId: item._id });
}

// ✅ DO: Aggregate and log summary
logger.info("Processing items", { count: items.length });
// ... process items
logger.info("Items processed", { total: items.length, success: successCount });

// ❌ DON'T: Log passwords or secrets
logger.debug("Auth payload", { username, password, apiKey });

// ✅ DO: Redact sensitive data
logger.debug("Auth payload", { username, hasPassword: !!password });

// ❌ DON'T: Use string concatenation
logger.info(`User ${userId} updated profile`);

// ✅ DO: Use structured objects
logger.info("User updated profile", { userId });

// ❌ DON'T: Use console.error in catch blocks (ESLint will prevent this)
try {
  // some operation
} catch (error) {
  console.error(error); // ESLint error: no-restricted-syntax
}

// ✅ DO: Use logger.error for error handling
try {
  // some operation
} catch (error) {
  logger.error("Operation failed", { error, context: "someOperation" });
  throw error; // Re-throw if needed
}

đŸ§Ē Testing

Verify Log Output

// Test that logs appear at correct levels
export const testLogging = mutation({
  args: { testLevel: logLevel },
  handler: async (ctx, args) => {
    const logger = createLogger(args.testLevel);

    logger.debug("This is a debug message");
    logger.info("This is an info message");
    logger.warn("This is a warning message");
    logger.error("This is an error message");

    return { logLevel: args.testLevel };
  },
});

// Call with different levels to verify filtering
await testLogging({ testLevel: "DEBUG" }); // All messages
await testLogging({ testLevel: "INFO" }); // Info, warn, error
await testLogging({ testLevel: "WARN" }); // Warn, error
await testLogging({ testLevel: "ERROR" }); // Error only

📚 API Reference

createLogger()

function createLogger(level?: LogLevel): Logger;

Parameters:

  • level (optional): Log level ("DEBUG" | "INFO" | "WARN" | "ERROR")
    • Defaults to environment-based level via getDefaultLogLevel()

Returns: Logger instance with methods:

  • debug(...args: unknown[]): void
  • info(...args: unknown[]): void
  • warn(...args: unknown[]): void
  • error(...args: unknown[]): void
  • time(label: string): void
  • timeEnd(label: string): void

logger (default instance)

const logger: Logger;

Pre-configured logger instance using environment-based log level.

getDefaultLogLevel()

function getDefaultLogLevel(): LogLevel;

Returns the appropriate log level based on:

  1. LOG_LEVEL environment variable (if set)
  2. CONVEX_ENV mapping:
    • "development" → "DEBUG"
    • "preview" | "staging" → "INFO"
    • "production" → "ERROR"
    • undefined → "INFO" (with warning)

← Previous: Rate Limiting | Next: Triggers →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro