Tracing Vercel AI SDK applications

The open-source Vercel AI SDK is a popular choice for building generative AI applications due to its ease of use and integrations with popular frameworks, such as Next.js. However, builders recognize that to reach production, they also need to incorporate observability into their applications. This cookbook will show you how to use Braintrust's native integration with the Vercel AI SDK for logging and tracing a generative AI application.

Getting started

To get started, make sure you have the following ready to go:

In this cookbook, we're going to use a simple chat application that gives you the temperature when you ask about the weather in a given city. The chatbot uses an OpenAI model, which calls one tool that gets the weather from open-meteo and another tool that converts the weather from Celsius to Fahrenheit.

Use npx to download the application locally:

npx create-next-app@latest --example https://github.com/braintrustdata/braintrust-cookbook/tree/main/examples/VercelAISDKTracing/complete-weather-app vercel-ai-sdk-tracing && cd vercel-ai-sdk-tracing

We'll only edit a few files in this example application:

complete-weather-app/
  |_ app/(preview)/api/chat/route.ts
  |_ components/tools.ts
  |_ .env.local

For the application to run successfully, you'll need to rename the .env.local.example file to .env.local in the root of the project and add the following environment variables:

BRAINTRUST_API_KEY="<Your Braintrust API Key>"
BRAINTRUST_PROJECT_NAME="<Your Braintrust Project Name>"
OPENAI_API_KEY="<Your OpenAI API Key>"
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.braintrust.dev/otel
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <Your Braintrust API Key>, x-bt-parent=project_id:<Your Braintrust Project Name>"

Run the application, and make sure you can access it at http://localhost:3000. Feel free to test the application by asking it about the weather in Philadelphia.

npm install
npm run dev

It should look like this:

Screenshot of the application

Tracing the application

Initializing a logger

To send logs to Braintrust, you'll need to initialize a logger by calling the initLogger function. This function takes an apiKey and a projectName as arguments. The apiKey is your Braintrust API key, and the projectName is the name of your project in Braintrust. For lines 1-11 in the app/(preview)/api/chat/route.ts file, uncomment the lines where instructed to load the necessary Braintrust functions and initialize the logger. Lines 1-11 should look like this:

app/(preview)/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, ToolInvocation } from "ai";
import { getWeather, getFahrenheit } from "@/components/tools";
// Uncomment below to use Braintrust's tracing features
import { initLogger, wrapAISDKModel, traced, currentSpan } from "braintrust";
 
// Initialize Braintrust as the logging backend. Uncomment below
const logger = initLogger({
  apiKey: process.env.BRAINTRUST_API_KEY,
  projectName: process.env.BRAINTRUST_PROJECT_NAME,
});

Automatic tracing of models

The Braintrust SDK provides functions to "wrap" models, automatically logging inputs and outputs. When working with the Vercel AI SDK, you can use the wrapAISDKModel function, which provides a common interface for models initiated by the Vercel AI SDK (for example, @ai-sdk/openai).

The wrapAISDKModel function only traces the inputs and outputs of the model. It does not trace intermediary steps such as tool calls that may be invoked during the model's execution. Later in the cookbook, we will explore how to use wrapTraced to trace tool calls and nested functions.

The wrapAISDKModel must be used with a Vercel interface (not a model interface directly from the OpenAI, Anthropic, or other first-party libraries from model providers). If you are not using Vercel model interfaces (such as @ai-sdk/openai) and using a model provider's first-party library, you can wrap your model clients with wrapOpenAI or wrapAnthropic.

To correctly wrap the model client in our weather app example, your model client instantiation code should look like this after uncommenting the proper lines:

app/(preview)/api/chat/route.ts
// Any time this model is called, the input and output will be logged to Braintrust.
const model = wrapAISDKModel(openai("gpt-4o"));

When we use the chatbot again, we see three logs appear in Braintrust: one log for the getWeather tool call, one log for the getFahrenheit tool call, and one call to form the final response. However, it'd probably be more useful to have all of these operations in the same log.

using wrapAISDKModel

Creating spans (and sub-spans)

When tracing events, it's common practice to place child events within a single parent event. As an example, take grouping the three logs that we produced above into the same log record. You can do this using the traced function.

To create a parent span in our weather app, uncomment the traced function (don't forget to uncomment the final line of code that closes the function). You can also uncomment the onFinish argument, which will log the input and output of the streamText function to the parent span. Your POST route should look like this when finished:

app/(preview)/api/chat/route.ts
export async function POST(request: Request) {
  // traced starts a trace span when the POST endpoint is used
  // Unlike wrapTraced, traced does not natively log inputs and outputs. Uncomment below
  return traced(
    async (span) => {
      const { messages }: { messages: Message[] } = await request.json();
 
      const stream = await streamText({
        // Our wrapped OpenAI model
        model: model,
        system: `\
        - you are an AI assistant who gives the weather. If the user gives you a location, give them the current weather in that location in Fahrenheit.
      `,
        messages: messages,
        // Important: maxSteps prevents infinite tool call loops but will stop your LLM's logic prematurely if set too low
        maxSteps: 5,
        // Register the exported tools to the LLM from @/components/tools
        tools: {
          getWeather: getWeather,
          getFahrenheit: getFahrenheit,
        },
        // Enable experimental telemetry
        experimental_telemetry: {
          isEnabled: true,
        },
        // When streamText is finished, log the input and output of the stream for the "root" span. Uncomment below
        onFinish: (result) => {
          currentSpan().log({
            input: messages,
            output: result.text,
          });
        },
      });
 
      return stream.toDataStreamResponse();
    },
    // Show this span as a function and name the span POST /api/chat. Uncomment below
    { type: "function", name: "POST /api/chat" },
  );
}

After you uncomment those lines of code, you should see the following:

using trace to create spans

A couple of things happened in this step:

  • We created a root span called "POST /api/chat" to group any subsequent logs into.
  • We continued to create spans via the wrapAISDKModel function.
  • We used the onFinish argument of the streamText function to gather the input and output of the LLM and return it to the root span.

This looks good so far, but we also want to know about the different tool calls that the LLM is making as it works to form its response.

Tracing tool calls

The last thing that we need to adjust is adding our tool calls and functions to the trace. You can do this by encapsulating existing functions with wrapTraced, which will automatically capture the inputs and outputs of the functions. When using wrapTraced, the hierarchy of nested functions is preserved.

The following code in components/tools.ts has two main components:

  1. A getFahrenheit tool, which converts a Celsius temperature into Fahrenheit. It also nests the checkFreezing function inside the convertToFahrenheit function.
  2. A getWeather tool which takes a latitude and longitude as input and returns a Celsius temperature as output.

Uncomment the code where noted so that your tools.ts file looks like this:

import { tool } from "ai";
import { z } from "zod";
// Uncomment below to use Braintrust's tracing features
import { wrapTraced, currentSpan } from "braintrust";
 
interface LocationInput {
  latitude: number;
  longitude: number;
}
 
// Create a simple function to note whether or not a Fahrenheit temperature is freezing
// Wrap the function with the wrapTraced function to note inputs and outputs. Uncomment wrapTraced below
const checkFreezing = wrapTraced(
  async function checkFreezing({ fahrenheit }: { fahrenheit: number }) {
    return fahrenheit < 32;
  },
  // Uncomment below
  { type: "function" },
);
 
// Create a function that takes a temperature in Celsius and returns the temperature in Fahrenheit
// Wrap the function with the wrapTraced function to note inputs and outputs. Uncomment wrapTraced
const convertToFahrenheit = wrapTraced(
  async function convertToFahrenheit({ celsius }: { celsius: number }) {
    const fahrenheit = (celsius * 9) / 5 + 32;
    const isFreezing = checkFreezing({ fahrenheit });
    return fahrenheit;
  },
  // Uncomment below
  { type: "tool" },
);
 
// Construct a tool using the tool() function in the AI package to place in the LLM call
export const getFahrenheit = tool({
  description: "Convert Celsius to Fahrenheit",
  parameters: z.object({ celsius: z.number() }),
  execute: convertToFahrenheit,
});
 
// Create a function that fetches a temperature in Celsius from open-meteo
// Wrap the function with the wrapTraced function to note inputs and outputs. Note that the function should be logged as a tool in the trace. Uncomment wrapTraced below
const weatherFunction = wrapTraced(
  async function weatherFunction({ latitude, longitude }: LocationInput) {
    const response = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
    );
    const weatherData = await response.json();
    // Uncomment below to add metadata to the span
    currentSpan().log({
      metadata: { foo: "bar" },
    });
    return weatherData;
  },
  // Uncomment below
  { type: "tool", name: "weatherFunction" },
);
 
// Construct a tool using the tool() function in the AI package to place in the LLM call
export const getWeather = tool({
  description: "Get the current weather at a location",
  parameters: z.object({
    latitude: z.number(),
    longitude: z.number(),
  }),
  execute: weatherFunction,
});

After we finish uncommenting the correct lines, we see how the wrapTraced function enriches our trace with tool calls.

using wrapTraced

Take note of how the type argument in both traced and wrapTraced change the icon within the trace tree. Also, since checkFreezing was called by weatherFunction, the trace preserves the hierarchy.

Next steps

  • Customize and extend traces to better optimize for your use case
  • Read more about Brainstore, the database that powers the logging backend in Braintrust

On this page