Boundary Studio

For 2025 Q1, Boundary Studio is free for new accounts!

Boundary Studio 2 will be released in 2025 Q2 with a new pricing model.

To enable observability with BAML, you’ll first need to sign up for a Boundary Studio account.

Once you’ve signed up, you’ll be able to create a new project and get your API key.

Then simply add the following environment variable prior to running your application:

$export BOUNDARY_API_KEY=your_api_key_here

There you’ll be able to see all the metrics and logs from your application including:

  • Cost
  • Function calls
  • Execution time
  • Token Usage
  • Prompt Logs
  • and more…

Tracing Custom Events

BAML allows you to trace any function with the @trace decorator. This will make the function’s input and output show up in the Boundary dashboard. This works for any python function you define yourself. BAML LLM functions (or any other function declared in a .baml file) are already traced by default. Logs are only sent to the Dashboard if you setup your environment variables correctly.

Example

In the example below, we trace each of the two functions pre_process_text and full_analysis:

1from baml_client import baml
2from baml_client.types import Book, AuthorInfo
3from baml_client.tracing import trace
4
5# You can also add a custom name with trace(name="my_custom_name")
6# By default, we use the function's name.
7@trace
8def pre_process_text(text):
9 return text.replace("\n", " ")
10
11
12@trace
13async def full_analysis(book: Book):
14 sentiment = await baml.ClassifySentiment(
15 pre_process_text(book.content)
16 )
17 book_analysis = await baml.AnalyzeBook(book)
18 return book_analysis
19
20
21@trace
22async def test_book1():
23 content = """Before I could reply that he [Gatsby] was my neighbor...
24 """
25 processed_content = pre_process_text(content)
26 return await full_analysis(
27 Book(
28 title="The Great Gatsby",
29 author=AuthorInfo(firstName="F. Scott", lastName="Fitzgerald"),
30 content=processed_content,
31 ),
32 )

This allows us to see each function invocation, as well as all its children in the dashboard:

Adding custom tags

The dashboard view allows you to see custom tags for each of the function calls. This is useful for adding metadata to your traces and allow you to query your generated logs more easily.

To add a custom tag, you can import set_tags(..) as below:

1from baml_client.tracing import set_tags, trace
2import typing
3
4@trace
5async def pre_process_text(text):
6 set_tags(userId="1234")
7
8 # You can also create a dictionary and pass it in
9 tags_dict: typing.Dict[str, str] = {"userId": "1234"}
10 set_tags(**tags_dict) # "**" unpacks the dictionary
11 return text.replace("\n", " ")

Tags on BAML calls and retrieving them with the Collector

You can also set tags directly on a BAML function call and then retrieve them from the Collector. Tags from a parent trace are inherited by the BAML function call and merged with any function-specific tags you pass.

1from baml_client import b
2from baml_client.tracing import trace, set_tags
3from baml_py import Collector
4
5@trace
6async def parent_fn(msg: str):
7 # Set tags on the parent trace (these propagate to child BAML calls)
8 set_tags(parent_id="p123", run="xyz")
9
10 collector = Collector(name="tags-collector")
11
12 # You can also set per-call tags via baml_options
13 await b.TestOpenAIGPT4oMini(
14 msg,
15 baml_options={
16 "collector": collector,
17 "tags": {"call_id": "first", "version": "v1"},
18 },
19 )
20
21 # Retrieve tags from the last function log
22 log = collector.last
23 assert log is not None
24 print(log.tags) # {"parent_id": "p123", "run": "xyz", "call_id": "first", "version": "v1"}

Notes:

  • Tags from set_tags/setTags on a parent trace are merged into the BAML function’s tags.
  • Per-call tags are provided via baml_options in Python and the options object in TypeScript; in Go use b.WithTags(map[string]string).
  • Retrieve tags from a FunctionLog using log.tags (Python/TypeScript) or log.Tags() (Go).

Tracing with ThreadPoolExecutor (Python)

When using Python’s concurrent.futures.ThreadPoolExecutor, traced functions submitted to the thread pool will start with fresh, independent tracing contexts. This is by design and differs from async/await execution.

Expected Behavior

Python
1from concurrent.futures import ThreadPoolExecutor
2from baml_client.tracing import trace
3
4@trace
5def parent_function():
6 with ThreadPoolExecutor() as executor:
7 # Submit worker to thread pool
8 future = executor.submit(worker_function, "data")
9 result = future.result()
10
11@trace
12def worker_function(data):
13 # This will be an independent root trace
14 # NOT a child of parent_function
15 process_data(data)
16
17@trace
18def process_data(data):
19 # This WILL be a child of worker_function
20 # (same thread execution)
21 return data.upper()

In the trace hierarchy, you’ll see:

  • parent_function as a root trace (depth 1)
  • worker_function as an independent root trace (depth 1) - not a child
  • process_data as a child of worker_function (depth 2)

Why This Happens

Python’s contextvars (used for tracing context) don’t automatically propagate to thread pool threads. Each worker thread starts with a fresh context to:

  • Avoid complexity with context sharing across threads
  • Prevent potential race conditions
  • Maintain clear thread boundaries

Best Practices

  1. Use async/await for related work: If you need to maintain parent-child relationships for parallel execution, use asyncio instead of thread pools:
1@trace
2async def parent_async():
3 # These will maintain parent-child relationship
4 results = await asyncio.gather(
5 async_worker("task1"),
6 async_worker("task2")
7 )
  1. Understand the trace hierarchy: When debugging, remember that thread pool workers appear as separate root traces in your observability dashboard.

  2. Tags don’t propagate: Tags set in the parent function won’t automatically appear in thread pool workers since they have independent contexts.