Vercel Logo

Bolt Middleware: Add Cross-Cutting Concerns Once, Use Everywhere

You need to add auth checks to 15 listeners. Or log every request. Or track rate limits. Copy-pasting the same code into every handler is fragile—miss one and you have a gap. Bolt middleware lets you write it once and it runs for every event automatically. This lesson teaches the pattern using correlation logging as the example, but you'll use middleware for auth, metrics, feature flags, and more throughout the course.

Outcome

Understand how to use Bolt middleware to add cross-cutting concerns (logging, auth, metrics) that run automatically for every event, demonstrated through correlation tracking.

Fast Track

  1. Create server/listeners/types.ts with correlation type definitions
  2. Add correlation middleware to server/app.ts before listeners
  3. Update app-mention.ts to use structured logging with correlation fields
  4. Test with bot mention and verify event_id appears in logs
Prerequisites

You MUST complete Lesson 1.3 (Boot Checks & Health) before starting this lesson.

That lesson created server/env.ts with a Zod-validated env object that replaces process.env. All code in this lesson uses env.SLACK_BOT_TOKEN and env.NODE_ENV instead of process.env.*. The code won't work without it.

How Bolt Middleware Works

Middleware runs before your event listeners. Write it once, it applies to every event automatically:

Slack Event → [Nitro] → [Receiver] → [Middleware] → [Your Listener]
                                        ↑
                                   Runs for EVERY event:
                                   - Add context (correlation, auth)
                                   - Log requests
                                   - Check permissions
                                   - Track metrics

This lesson: Use middleware to add correlation fields to context.
Later lessons: Use the same pattern for auth checks, rate limiting, and feature flags.

The pattern is the point—correlation is just the teaching example.

VercelReceiver, Not Socket Mode

This course uses VercelReceiver to connect Bolt to Nitro’s HTTP handler instead of Socket Mode. Socket Mode keeps a WebSocket open from your bot to Slack (great for on-prem or dev tunnels), while VercelReceiver turns Slack’s HTTP events into Bolt requests that run on Vercel/Nitro. That’s why you see receiver = new VercelReceiver(...) in server/app.ts and HTTP event subscriptions in your manifest—not app.start() or Socket Mode flags.

Structured Logging with Pino

Bolt uses pino for logging. The signature is logger.info(object, message) not logger.info(message). The object comes first so log aggregators (Datadog, CloudWatch, etc.) can index these fields for queries like event_id='Ev123'. This is different from console.log where you'd just log a string.

Example:

// ❌ Old style (not queryable)
logger.info(`Processing event ${event_id} from user ${user}`);
 
// ✅ New style (queryable by event_id or user)
logger.info({ event_id, user }, "Processing event");

Hands-On Exercise 2.2

Create middleware that adds context to all events for better debugging:

Requirements:

  1. Create correlation middleware in server/app.ts that extracts event identifiers
  2. Add event_id, ts, and thread_ts to context.correlation
  3. Update at least one listener to use correlation fields in logging
  4. Test that you can trace a single event through all log layers

Implementation hints:

  • Use app.use() before registerListeners() to ensure middleware runs first
  • Extract fields from body and payload - they have different structures
  • Spread context.correlation into logger calls for structured logging
  • Test both success and error paths to verify correlation persists

Key files:

  • /slack-agent/server/app.ts - Middleware registration
  • /slack-agent/server/listeners/events/app-mention.ts - Example listener to update

Try It

  1. Start the app and mention your bot:

    • Run slack run
    • In Slack, mention your bot: @yourbot hello
    • Watch terminal for correlated logs
  2. Verify correlation fields appear: You should see something like this (exact format varies by terminal):

    [INFO]  bolt-app {
      event_id: 'Ev09E5EDA89M',
      ts: '1757523346.437659',
      thread_ts: undefined,
      channel: 'C09D4DG727P',
      user: 'U09D6B53WP4'
    } Processing app_mention
    

    What to look for: event_id and ts are present in the log. The exact formatting (spacing, colors, brackets) depends on your terminal and logger configuration. thread_ts will be undefined for top-level mentions and only has a value when someone replies in a thread.

  3. Test error correlation (optional):

    • Temporarily throw an error in your listener
    • Verify error logs also include correlation fields

Commit

git add -A
git commit -m "feat(logging): add correlation middleware for event tracing
 
- Add middleware to extract event_id, ts, thread_ts
- Attach correlation context to all events
- Update app-mention listener with structured logging
- Verify correlation fields appear in logs"

Done-When

  • Created server/listeners/types.ts with context type augmentation
  • Created correlation middleware in server/app.ts
  • Middleware extracts event_id, ts, and thread_ts into context.correlation
  • Updated app-mention.ts to log correlation fields in three places (initial log + both error handlers)
  • Verified you can trace a single event through logs using event_id
  • (Optional) Applied correlation logging to other listeners for full coverage
Extend to All Listeners

For production-ready correlation, apply this same logging pattern to your other listeners: slash commands (server/listeners/commands/), shortcuts, direct messages, etc. The middleware already provides context.correlation everywhere—you just need to spread it into your log calls.

Step by Step Solution

Step 1: Add correlation middleware and TypeScript types

Create a type definition file that extends Bolt's context and defines payload types:

/server/listeners/types.ts
import "@slack/bolt";
 
// Slack event payload types (not fully exposed by Bolt's TypeScript definitions)
export type SlackEventBody = { 
  event_id?: string;
};
 
export type SlackEventPayload = { 
  ts?: string;
  message_ts?: string;
  thread_ts?: string;
  item?: { ts?: string };
};
 
// Extend Bolt's Context type to include our correlation fields
// This is TypeScript module augmentation - we're extending Bolt's existing Context interface
// without modifying their code. Now context.correlation will autocomplete in your IDE.
declare module "@slack/bolt" {
  interface Context {
    correlation?: {
      event_id?: string;
      ts?: string;
      thread_ts?: string;
    };
  }
}
Why Type Casting?

The fields (event_id, ts, etc.) ARE present at runtime—Slack's API guarantees them. Bolt validates and parses these payloads but doesn't expose them in TypeScript types. This is a limitation of Bolt's type definitions, not your code. We define minimal types with just the fields we need, then cast once. This documents expectations without any casts everywhere.

Then add the middleware to server/app.ts after creating the app but before registering listeners:

/server/app.ts
import { App, LogLevel } from "@slack/bolt";
import { VercelReceiver } from "@vercel/slack-bolt";
import registerListeners from "./listeners";
import { env } from "./env";
import { SlackEventBody, SlackEventPayload } from "./listeners/types";
 
const logLevel =
  env.NODE_ENV === "development" ? LogLevel.DEBUG : LogLevel.INFO;
 
const receiver = new VercelReceiver({
  logLevel,
});
 
const app = new App({
  token: env.SLACK_BOT_TOKEN,
  signingSecret: env.SLACK_SIGNING_SECRET,
  receiver,
  deferInitialization: true,
  logLevel,
});
 
// Add correlation middleware (runs after Bolt parses the event but before routing to listeners)
app.use(async ({ context, body, payload, next }) => {
  // Cast to our defined types (Bolt validates these, but doesn't expose them in types)
  const b = body as SlackEventBody;
  const p = payload as SlackEventPayload;
  
  // Slack uses different timestamp field names depending on event type:
  // - Regular events (app_mention, etc.): payload.ts
  // - Message events: payload.message_ts
  // - Reaction events: payload.item.ts
  // We check all three to handle any event type
  context.correlation = {
    event_id: b.event_id,
    ts: p.ts ?? p.message_ts ?? p.item?.ts,
    thread_ts: p.thread_ts,
  };
  
  // Pass control to the next middleware or listener
  // Without this, the request pipeline stops here and your listeners won't run
  await next();
});
 
registerListeners(app);
 
export { app, receiver };

Step 2: Update a listener to use correlation

Add correlation logging to server/listeners/events/app-mention.ts in three locations:

Edit 1: Replace the existing debug log (at the start of the function, after the parameter destructuring):

const appMentionCallback = async ({
  event,
  say,
  client,
  logger,
  context,
}: AllMiddlewareArgs & SlackEventMiddlewareArgs<"app_mention">) => {
  // Replace this:
  logger.debug(`app_mention event received: ${JSON.stringify(event)}`);
  
  // With this:
  logger.info({
    ...context.correlation,
    channel: event.channel,
    user: event.user,
  }, "Processing app_mention");
  
  const thread_ts = event.thread_ts || event.ts;
  const channel = event.channel;
  // ... rest of the function

Edit 2: Update the main error handler (in the first catch block):

  try {
    // ... AI streaming logic ...
    
    await streamer.stop({
      blocks: [feedbackBlock({ thread_ts })],
    });
  } catch (error) {
    // Replace this:
    logger.error("app_mention handler failed:", error);
    
    // With this:
    logger.error({
      ...context.correlation,
      error: error instanceof Error ? error.message : String(error),
    }, "app_mention handler failed");
    
    try {
      await say({
        text: "Sorry, something went wrong...",
        thread_ts: event.thread_ts || event.ts,
      });
    // ... rest of error handling

Edit 3: Update the fallback error handler (in the nested catch block):

  } catch (error) {
    logger.error({
      ...context.correlation,
      error: error instanceof Error ? error.message : String(error),
    }, "app_mention handler failed");
    try {
      await say({
        text: "Sorry, something went wrong processing your message. Please try again.",
        thread_ts: event.thread_ts || event.ts,
      });
    } catch (error) {
      // Replace this:
      logger.error("Failed to send error response:", error);
      
      // With this:
      logger.error({
        ...context.correlation,
        error: error instanceof Error ? error.message : String(error),
      }, "Failed to send error response");
    }
  }
};

Step 3: Test correlation

  1. Run slack run
  2. Mention your bot in a channel
  3. Look for correlated logs like this real example:
[INFO]  bolt-app {
  event_id: 'Ev09E5EDA89M',
  ts: '1757523346.437659',
  thread_ts: undefined,
  channel: 'C09D4DG727P',
  user: 'U09D6B53WP4'
} Processing app_mention

Step 4: Test the error path (optional)

To see error correlation in action, temporarily break something:

/slack-agent/server/listeners/events/app-mention.ts
// Add this line right after the try { to force an error
throw new Error("Testing correlation in error logs");

You'll see:

[ERROR] bolt-app {
  event_id: 'Ev09E5EDA89M',
  ts: '1757523346.437659',
  thread_ts: undefined,
  error: 'Testing correlation in error logs'
} app_mention handler failed

The same event_id and ts appear in both success and error logs, making it trivial to trace what went wrong for a specific user interaction.

Troubleshooting

Context.correlation is undefined:

  • Check middleware is added before registerListeners(app)
  • Verify app.use() is called after creating the app

Correlation fields missing from logs:

  • Ensure you're spreading ...context.correlation in logger calls
  • Check the middleware is actually running (add a console.log to verify)