Orchestrate

Set up for Unit 10: observability and containers

Install a container engine, run the Phoenix trace viewer, send your first OpenTelemetry trace of an AI request, and confirm your BTP trial is ready for deployment.

Updated Oct 6, 2026Foundational 8 minDeep 40 min
Foundational layer · 8 min read

The 60-second version

Unit 10 is about running AI in production: knowing what it does, what it costs and how fast it is. Two kinds of tools make that possible, and this setup installs both on your laptop.

  • A container engine. A container is a sealed box holding a program and everything it needs to run. The same box runs on a laptop, a server or a cloud platform. Docker Desktop is the best-known engine; free alternatives exist.
  • A trace viewer. A trace is the story of one request: which steps ran, in what order, how long each took, and how many tokens the model used. You will run Phoenix, a free open-source viewer, inside a container.

You will also send your first trace from Python with OpenTelemetry, the open standard most observability tools accept. Then you will check that your SAP BTP trial still lets you deploy, because the unit ends by running AI on BTP.

Setup takes 40 to 70 minutes. For a learner it costs nothing. For a company, one item can: Docker Desktop needs a paid subscription in larger organisations.

Why it matters to the business

An AI assistant that works in a demo can still fail in production in ways nobody sees. Answers get slow at month end. A model update doubles token use. A tool call to SAP starts timing out. Without traces, each of these surfaces as a vague complaint, and the team guesses.

Take the running example: an assistant that explains blocked sales orders to order-to-cash clerks. A trace of one question shows four steps: search the block-reason notes, read the order from SAP, ask the model, answer. If the answer took eight seconds, the trace says which step took seven. That turns a complaint into a fix.

Observability also pays for itself in three plainer ways.

  • Cost control. Token counts per request are recorded on every trace. Finance can see cost per answered question, not only a monthly bill.
  • Accountability. When an auditor or a user asks "why did the assistant say that?", a trace shows which data and which tools were used.
  • Portability. OpenTelemetry is vendor-neutral. Instrument once, then send the same data to a laptop viewer today and to the company's monitoring platform later.

Containers matter for a related reason. A container packages exactly what was tested. It is the common unit of deployment on Kubernetes, including SAP's own Kubernetes runtime on BTP.

How SAP does it

As of October 2026, SAP's managed path for this lives on SAP BTP.

  • SAP Cloud Logging is SAP's managed observability service. An August 2025 case study on the OpenSearch project's site describes it as built on OpenSearch, with native OTLP ingestion (OTLP is OpenTelemetry's protocol) enabled on part of its instances. So the traces you learn to send here use the same protocol it accepts.
  • The Kyma runtime, SAP's Kubernetes runtime on BTP, has a Telemetry module. Its documentation describes sending logs, traces and metrics over OTLP to an SAP Cloud Logging instance "with OpenTelemetry ingestion enabled". Kyma runs containers, which is why this setup installs a container engine.
  • The BTP trial you created in Unit 3 is still offered free for 90 days from registration, and needs a login at least every 30 days. Unit 10's BTP topic uses it.

We could not confirm from SAP's own pages, opened on 6 October 2026, whether SAP Cloud Logging can be added to a trial account. This course therefore teaches tracing with Phoenix on your laptop, and treats SAP Cloud Logging as the production destination.

What this unit adds

Item What it is Cost Used in
A container engine (Docker Desktop, Docker Engine or Podman Desktop) Runs containers on your laptop Free for learners; Docker Desktop is paid in larger companies Observability; running AI on BTP
Docker Compose Starts several containers from one file Free This setup onwards
Phoenix (in a container) Open-source trace viewer for AI apps Free to self-host Observability; latency; token economics
OpenTelemetry Python libraries Record traces from your code and send them Free Every Unit 10 topic
Your BTP trial and cf tool (from Units 3 and 6) Where apps get deployed Free, 90 days Running AI on SAP BTP in production

Time and money

  • Time: 40 to 70 minutes. The container engine install is the slowest part, and Windows may need a restart.
  • Money for a learner: nothing. Phoenix and OpenTelemetry are free, Docker Desktop is free for personal use, and the setup calls no model.
  • Money for a company: Docker's licence says Docker Desktop is free only for organisations with fewer than 250 employees and less than $10 million in annual revenue (plus personal, education and non-commercial open-source use). Larger organisations need a paid subscription. Podman Desktop and Docker Engine on Linux are free alternatives.
  • Later in the unit: tracing itself is cheap. What it reveals, such as token spend per request, is the subject of the token economics topic.

Questions to ask

  • Do we have Docker Desktop licences, or a standard alternative such as Podman Desktop? Who approves a developer install?
  • May developers run containers on their laptops at all? Some companies block virtualisation.
  • Where do production traces go: SAP Cloud Logging, another monitoring platform, or nowhere yet? Who owns it?
  • What may a trace contain? Prompts and answers can hold customer data. Who decides what is recorded and how long it is kept?
  • Which BTP runtime will our AI apps run in, Cloud Foundry or Kyma? Containers matter more for Kyma.

Common misconceptions

  • "Docker is free, full stop." Docker Engine is open source. Docker Desktop, the app most people install on Windows and Mac, needs a paid subscription in larger companies.
  • "Logs are enough." A log line says something happened. A trace connects every step of one request and times each one, which is what you need to find a slow or expensive step.
  • "Tracing means recording every prompt." OpenTelemetry's AI conventions don't capture prompt content by default. What you record is a decision, and the safe default is facts, not text.
  • "A trace viewer locks us in." OpenTelemetry is the open standard. The same instrumented code can send to Phoenix today and to SAP Cloud Logging tomorrow.
  • "Containers are only for Kubernetes experts." You will run one with one command. The depth comes later, and only if your runtime needs it.

Key terms

  • Container: a sealed, portable package of a program and everything it needs.
  • Image: the template a container starts from; a container is a running image.
  • Container engine: the software that runs containers, such as Docker or Podman.
  • Docker Compose: a file and command that start several containers together.
  • Observability: being able to tell what a running system is doing from the data it emits.
  • Trace: the record of one request through every step.
  • Span: one step inside a trace, with a start, an end and labelled facts.
  • OpenTelemetry (OTel): the open standard for traces, metrics and logs.
  • OTLP: OpenTelemetry's protocol for sending that data.
  • Phoenix: an open-source trace viewer built for AI applications.
  • SAP Cloud Logging: SAP's managed observability service on BTP.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1An assistant for blocked sales orders is slow at month end. What does a trace give the team that a complaint does not?

    Answer: B. A trace records every step of one request with its timing, so the team can see whether retrieval, the SAP call or the model took the time. It measures; it does not fix or alert by itself.
  2. 2Your company has 4,000 employees. What should you check before developers install Docker Desktop?

    Answer: C. Docker's licence makes Docker Desktop free only for organisations under 250 employees and $10 million revenue, plus personal, education and open-source use. Larger companies need a subscription or a free alternative such as Podman Desktop.
  3. 3Why does the course teach OpenTelemetry rather than one vendor's tracing library?

    Answer: C. Instrumenting with the open standard means the same traces can go to Phoenix on a laptop and to a managed service such as SAP Cloud Logging later. It does not capture prompt content by default.
  4. 4Which SAP offering is the managed destination for traces from apps on BTP?

    Answer: C. SAP Cloud Logging is SAP's managed observability service, and the Kyma Telemetry module sends logs, traces and metrics to it over OTLP. The other offerings are for building, administration and model access.
  5. 5A team wants to record every prompt and answer in traces "to be safe". What is the main risk to raise?

    Answer: B. Prompts and answers often carry customer or employee data, so recording them is a data decision with retention and access rules. OpenTelemetry's AI conventions leave content out by default for this reason.
  6. 6Why does a setup for production topics include checking the BTP trial?

    Answer: C. The trial lasts 90 days from registration and needs a login at least every 30 days, so a learner who started in Unit 3 may need a new one. The unit's last topics deploy to BTP.
Deep layer · 40 min read

Mental model: a sealed box and a flight recorder

This unit gives you two tools that answer two production questions.

  • "Will it run the same everywhere?" A container is a sealed box: your program plus the exact libraries and settings it was tested with. The box behaves the same on your laptop and on a Kubernetes cluster.
  • "What did it just do?" A trace is a flight recorder for one request. Each step writes a span with a start time, an end time and labelled facts such as the model name and token counts.
flowchart LR
  subgraph Laptop
    A[Your Python app<br/>with OpenTelemetry] -->|OTLP over HTTP| P[Phoenix<br/>in a container]
    B[Browser] -->|localhost:6006| P
  end
  A -. same protocol later .-> C[SAP Cloud Logging<br/>or another backend]

The dotted line is the point. You instrument the code once. Where the traces go is configuration.

How it works

Containers in five words

Word Meaning In this setup
Image A read-only template: an operating system layer, Python, your code arizephoenix/phoenix, and one you build from a Dockerfile
Container A running instance of an image Phoenix running on your laptop
Registry Where images are stored and downloaded from Docker Hub, where the Phoenix image is published
Port mapping Connects a port on your laptop to a port inside the container 127.0.0.1:6006:6006
Volume Storage that outlives the container Phoenix keeps its traces in one

Docker Compose describes several containers in one compose.yaml file. Inside Compose, containers find each other by service name: your app reaches Phoenix at http://phoenix:6006, not localhost. That one difference causes most first-day confusion.

Engines, and who pays

Engine Platforms Licence and cost, as of October 2026
Docker Desktop Windows, macOS Free for personal use, education, non-commercial open source and organisations under 250 employees and $10 million revenue; paid subscription otherwise
Docker Engine Linux Open source (Apache 2.0); free
Podman Desktop Windows, macOS, Linux Open source (Apache 2.0), a CNCF sandbox project; supports Docker CLI tools and Compose against the Podman engine

This topic's commands use the docker command. With Podman Desktop, its documentation describes how Docker tools and Compose work against the Podman engine; follow that page and the commands here carry over.

Traces, spans and OpenTelemetry

A trace has one root span (the whole request) and child spans for each step. Every span carries attributes, which are name-value pairs. A resource describes the program that produced the spans, such as service.name.

In code, the pieces are:

  1. A tracer provider, which owns the configuration.
  2. A span processor. BatchSpanProcessor collects finished spans and sends them in batches, off the request's path.
  3. An exporter, which says where spans go. OTLPSpanExporter sends over OTLP, OpenTelemetry's protocol; ConsoleSpanExporter prints them.

Naming AI steps the standard way

OpenTelemetry has semantic conventions for generative AI: agreed attribute names, so any tool can read your spans. They are still under active development, and they now live in their own OpenTelemetry repository. The ones this setup uses:

Attribute Example Meaning
gen_ai.operation.name chat, retrieval, execute_tool, invoke_agent What kind of step this is
gen_ai.provider.name example Which provider served the call
gen_ai.request.model example-small-model Which model was asked
gen_ai.usage.input_tokens 812 Tokens sent in
gen_ai.usage.output_tokens 96 Tokens generated

Phoenix's own convention is called OpenInference. Phoenix converts gen_ai.* attributes to OpenInference when spans arrive, so standard names show up as typed spans with token counts. We saw this in our test with Phoenix 20.19.0. The OpenTelemetry project notes that no prompt content is captured by default. This setup keeps it that way.

Build it yourself: run a trace viewer and send your first trace

You will install a container engine, start Phoenix in a container, send a trace of one made-up assistant request, run the same script inside a container, and confirm your BTP trial. No model is called and no account is needed except for Step 7.

Before you start: complete Set up your computer for this course, Set up for Unit 2, Set up for Unit 3 and Set up for Unit 6. They install Python, VS Code and Git, create your orchestrate-course folder with its .venv, .env and .gitignore, create your BTP trial and install the cf tool. This walkthrough doesn't repeat those steps.

flowchart LR
  S2[Step 2<br/>OTel libraries] --> S3[Step 3<br/>container engine]
  S3 --> S4[Step 4<br/>Phoenix]
  S4 --> S5[Step 5<br/>first trace]
  S5 --> S6[Step 6<br/>trace from a container]
  S6 --> S7[Step 7<br/>BTP trial]
  S7 --> S8[Step 8<br/>check_unit10.py]

What you need

  • Your course folder from earlier units, with .venv.
  • A computer that can run containers: Windows 10 22H2 or Windows 11 23H2 or later with 8 GB of memory and virtualisation turned on, a recent macOS, or a current Linux.
  • About 40 to 70 minutes, and about 2 GB of free disk space for the container images.
  • Your SAP account for Step 7 only.
  • Cost: free for learners. If you install Docker Desktop on a work laptop at a larger company, check your licence first (see the foundational layer).

Step 1: Open your course folder and turn on the virtual environment

  1. Open VS Code, choose File > Open Folder, and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. If the prompt doesn't start with (.venv), turn it on:

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate

Run every command in this topic from the course folder.

Step 2: Add the OpenTelemetry libraries

  1. Open requirements.txt and add these two lines at the end, then save:

    opentelemetry-sdk
    opentelemetry-exporter-otlp-proto-http

    The first records spans. The second sends them over OTLP using plain HTTP.

  2. Install (the same on every system):

    pip install -r requirements.txt
  3. Check it:

    pip show opentelemetry-sdk

What success looks like (from our test on 6 October 2026; your version may be newer):

Name: opentelemetry-sdk
Version: 1.45.1

Step 3: Install a container engine

Pick one of the options below. If your company has a standard (often Podman Desktop or licensed Docker Desktop), use that.

Option A: Docker Desktop on Windows

  1. Open PowerShell as administrator: click Start, type PowerShell, right-click Windows PowerShell and choose Run as administrator.

  2. Make sure WSL (the Windows Subsystem for Linux, which Docker Desktop uses to run Linux containers) is installed and current:

    wsl --install
    wsl --update

    If wsl --install prints help text instead, WSL is already installed; that's fine. If it installed WSL, restart your computer before going on.

  3. Open https://docs.docker.com/desktop/setup/install/windows-install/ and click the download button for Docker Desktop for Windows.

  4. Double-click Docker Desktop Installer.exe. Choose the per-user install if offered (it needs no administrator rights).

  5. On the Configuration page, keep Use WSL 2 instead of Hyper-V ticked. Finish the wizard and click Close.

  6. Start Docker Desktop from the Start menu. It shows the Docker Subscription Service Agreement; read it and click Accept. Docker Desktop won't run until you do.

  7. Wait until Docker Desktop says the engine is running.

Option B: Docker Desktop on macOS

  1. Open https://docs.docker.com/desktop/setup/install/mac-install/ and check that your macOS version is supported.
  2. Download the installer for your Mac: Apple silicon (M-series chips) or Intel. To check which you have, open the Apple menu and choose About This Mac.
  3. Open the downloaded file and follow Docker's page to move Docker into Applications.
  4. Start Docker from Applications and accept the Docker Subscription Service Agreement when it is shown.
  5. Wait until Docker Desktop says it is running.

Option C: Docker Engine on Linux (Ubuntu shown)

  1. Follow Docker's page https://docs.docker.com/engine/install/ubuntu/ under "Install using the apt repository". It ends by installing docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin and docker-compose-plugin.

  2. To run docker without sudo, add yourself to the docker group, then log out and back in:

    sudo usermod -aG docker $USER

Option D: Podman Desktop (any system)

Install Podman Desktop from https://podman-desktop.io, create a Podman machine when it asks, then follow its "Managing Docker compatibility" page to make the docker command and Compose work. Then continue with the same commands below.

Check it works (every option, in your VS Code terminal; open a new terminal first so it sees the new program):

docker --version
docker compose version
docker run --rm hello-world

What success looks like: two version lines, then the hello-world container downloads, prints a message that starts like this, and removes itself:

Hello from Docker!
This message shows that your installation appears to be working correctly.

Step 4: Start Phoenix with Docker Compose

  1. Make the Unit 10 folder:

    • Windows (PowerShell):

      New-Item -ItemType Directory -Force unit10
    • macOS / Linux:

      mkdir -p unit10
  2. In VS Code, right-click unit10, choose New File, name it compose.yaml, paste this and save. The demo part is for Step 6.

# Unit 10: a local trace viewer (Phoenix) and the traced demo, as containers.
#   docker compose -f unit10/compose.yaml up -d          start Phoenix in the background
#   docker compose -f unit10/compose.yaml run --rm demo  build and run trace_hello.py in a container
#   docker compose -f unit10/compose.yaml down           stop everything (your traces stay in the volume)
services:
  phoenix:
    image: arizephoenix/phoenix:latest  # pin a version number once a project is real
    ports:
      - "127.0.0.1:6006:6006"  # web UI and trace intake, reachable from this computer only
    environment:
      - PHOENIX_WORKING_DIR=/mnt/data
    volumes:
      - phoenix_data:/mnt/data  # keeps traces when the container is removed

  demo:
    build: .
    environment:
      # Inside Compose, containers reach each other by service name, not localhost.
      - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://phoenix:6006/v1/traces
    depends_on:
      - phoenix
    profiles: ["demo"]  # only runs when you ask for it by name

volumes:
  phoenix_data:
  1. Start Phoenix in the background:

    docker compose -f unit10/compose.yaml up -d

    The first run downloads the Phoenix image, which takes a few minutes. -d means "detached": the terminal comes back while Phoenix keeps running.

  2. Check it is running:

    docker compose -f unit10/compose.yaml ps

    You should see one service, phoenix, with a status starting with Up and the port 127.0.0.1:6006->6006/tcp.

  3. Open http://localhost:6006 in your browser. You see Phoenix with a Projects page. It is empty; you will fill it in the next step.

Step 5: Send your first trace

This script plays one request of the blocked-orders assistant. It doesn't call a model or SAP; it pretends to, with short pauses, so the trace has realistic shape at no cost. Each step becomes a span named with OpenTelemetry's AI conventions.

  1. Right-click unit10, choose New File, name it trace_hello.py, paste the code below and save.
"""Unit 10: send one traced request of the blocked-orders assistant to a trace viewer.

A trace is the story of one request: which steps ran, in what order, how long each took.
This script makes up one assistant request (no model is called, nothing costs money) and
records it with OpenTelemetry, the open standard for traces.

How to run (from your course folder, with .venv turned on):
    python unit10/trace_hello.py             send the trace to Phoenix on http://localhost:6006
    python unit10/trace_hello.py --console   print the trace in this terminal instead (no Docker needed)
"""
import argparse
import os
import random
import sys
import time
import urllib.error
import urllib.request

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter

# Where traces go. The standard OpenTelemetry variable wins if it is set (Docker Compose sets it).
ENDPOINT = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:6006/v1/traces")

# Made-up order, shaped like SAP's sales order API as in earlier units.
ORDER = {"SalesOrder": "9000001", "SoldToParty": "CUST-A", "DeliveryBlockReason": "01",
         "TotalCreditCheckStatus": "B"}
MODEL = "example-small-model"  # a label only: this script calls no model


def viewer_is_up(endpoint: str, attempts: int = 10) -> bool:
    """Return True if something answers at the trace viewer's address (retries for ~10 seconds)."""
    base = endpoint.split("/v1/")[0]
    for _ in range(attempts):
        try:
            urllib.request.urlopen(base, timeout=2)
            return True
        except urllib.error.HTTPError:
            return True  # it answered, even if with an error page: the server is there
        except (urllib.error.URLError, OSError):
            time.sleep(1)
    return False


def one_line(span) -> str:
    """Format a finished span as one short line for the terminal."""
    duration_ms = (span.end_time - span.start_time) / 1_000_000
    tokens = span.attributes.get("gen_ai.usage.input_tokens")
    extra = f"  tokens in/out: {tokens}/{span.attributes.get('gen_ai.usage.output_tokens')}" if tokens else ""
    return f"  span {span.name:<40} {duration_ms:6.0f} ms  trace {span.context.trace_id:032x}{extra}\n"


def setup_tracing(console: bool) -> TracerProvider:
    """Create the tracer provider: who we are (resource) and where spans go (exporter)."""
    resource = Resource.create({
        "service.name": "blocked-orders-assistant",
        "openinference.project.name": "orchestrate-unit10",  # Phoenix groups traces by project
    })
    provider = TracerProvider(resource=resource)
    if console:
        exporter = ConsoleSpanExporter(formatter=one_line)
    else:
        from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
        exporter = OTLPSpanExporter(endpoint=ENDPOINT)
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    return provider


def answer_question(tracer: trace.Tracer, question: str) -> str:
    """One assistant request: retrieve notes, call a tool, ask a model. Each step is a span."""
    with tracer.start_as_current_span("invoke_agent blocked-orders-assistant") as root:
        root.set_attribute("gen_ai.operation.name", "invoke_agent")
        # Record facts about the request, never the customer's data or the full prompt.
        root.set_attribute("app.question.length", len(question))
        root.set_attribute("app.sales_order", ORDER["SalesOrder"])

        with tracer.start_as_current_span("retrieval block-reason-notes") as span:
            span.set_attribute("gen_ai.operation.name", "retrieval")
            span.set_attribute("app.documents.returned", 3)
            time.sleep(random.uniform(0.08, 0.15))  # pretend to search a vector store

        with tracer.start_as_current_span("execute_tool get_sales_order") as span:
            span.set_attribute("gen_ai.operation.name", "execute_tool")
            span.set_attribute("gen_ai.tool.name", "get_sales_order")
            time.sleep(random.uniform(0.05, 0.12))  # pretend to call the SAP API
            blocked = bool(ORDER["DeliveryBlockReason"] or ORDER["TotalCreditCheckStatus"])
            span.set_attribute("app.order.blocked", blocked)

        with tracer.start_as_current_span(f"chat {MODEL}", kind=trace.SpanKind.CLIENT) as span:
            # Names from OpenTelemetry's generative AI conventions, so any viewer understands them.
            span.set_attribute("gen_ai.operation.name", "chat")
            span.set_attribute("gen_ai.provider.name", "example")
            span.set_attribute("gen_ai.request.model", MODEL)
            time.sleep(random.uniform(0.35, 0.6))  # pretend the model is generating
            span.set_attribute("gen_ai.usage.input_tokens", 812)
            span.set_attribute("gen_ai.usage.output_tokens", 96)

        answer = "Order 9000001 has a delivery block (01) and a failed credit check (B)."
        root.set_attribute("app.answer.length", len(answer))
        return answer


def main() -> None:
    parser = argparse.ArgumentParser(description="Send one traced assistant request to a trace viewer.")
    parser.add_argument("--console", action="store_true", help="print spans here instead of sending them")
    args = parser.parse_args()

    if not args.console and not viewer_is_up(ENDPOINT):
        print(f"No trace viewer answered at {ENDPOINT.split('/v1/')[0]}.")
        print("Start Phoenix first (Step 4), or run with --console to print the trace here.")
        sys.exit(1)

    provider = setup_tracing(args.console)
    tracer = trace.get_tracer("orchestrate.unit10")
    started = time.perf_counter()
    answer = answer_question(tracer, "Why is order 9000001 blocked?")
    elapsed_ms = (time.perf_counter() - started) * 1000
    print(f"Answer: {answer}")
    print(f"Request took {elapsed_ms:.0f} ms.")
    provider.shutdown()  # sends any spans still waiting in the batch
    if not args.console:
        print(f"Trace sent to {ENDPOINT}. Open http://localhost:6006 and choose project orchestrate-unit10.")


if __name__ == "__main__":
    main()
  1. First, try it without Phoenix. This path needs no container engine at all:

    python unit10/trace_hello.py --console

What success looks like (from our test; your times and trace ID will differ):

Answer: Order 9000001 has a delivery block (01) and a failed credit check (B).
Request took 709 ms.
  span retrieval block-reason-notes                 84 ms  trace 616d5f2145e1ea22c38a0f88f77991c4
  span execute_tool get_sales_order                 59 ms  trace 616d5f2145e1ea22c38a0f88f77991c4
  span chat example-small-model                    565 ms  trace 616d5f2145e1ea22c38a0f88f77991c4  tokens in/out: 812/96
  span invoke_agent blocked-orders-assistant       709 ms  trace 616d5f2145e1ea22c38a0f88f77991c4

Spans print as they finish, so the root span comes last. All four share one trace ID: that is what makes them one story.

  1. Now send it to Phoenix:

    python unit10/trace_hello.py
    Answer: Order 9000001 has a delivery block (01) and a failed credit check (B).
    Request took 576 ms.
    Trace sent to http://localhost:6006/v1/traces. Open http://localhost:6006 and choose project orchestrate-unit10.
  2. In the browser, refresh http://localhost:6006. Click the project orchestrate-unit10.

  3. On the Spans tab you see one row of kind agent, named invoke_agent blocked…. Click its name.

  4. A panel opens with the span tree on the left: the agent span, then retrieval, execute_tool and chat. Click the chat span. Its kind is LLM, and its attributes include the model name and the token counts 812 and 96.

  5. Run the script four more times. The project's Stats panel shows Latency P50 and P99 across your traces. You will use those numbers in the latency topic.

What success looks like (from our test with Phoenix 20.19.0): the retrieval span shows as kind retriever, the tool span as tool and the chat span as LLM. Phoenix did that from the standard gen_ai.operation.name values; you wrote nothing Phoenix-specific except the project name.

If the script says No trace viewer answered at http://localhost:6006, Phoenix isn't running. Go back to Step 4. A valid "empty" result: if the project list in Phoenix is empty after a successful run, refresh the page; the batch is sent when the script ends.

Step 6: Run the same script inside a container

So far Python ran on your laptop. Now you package the script into an image and run it next to Phoenix, the way it would run on Kubernetes.

  1. Right-click unit10, choose New File, name it Dockerfile (no extension), paste this and save:
# Unit 10: package trace_hello.py as a container image.
# A small official Python image; the version matches the course's Python.
FROM python:3.13-slim

WORKDIR /app

# Install only what this script needs. --no-cache-dir keeps the image smaller.
RUN pip install --no-cache-dir opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

COPY trace_hello.py .

# What runs when the container starts.
CMD ["python", "trace_hello.py"]
  1. Build the image and run it once, with Compose:

    docker compose -f unit10/compose.yaml run --rm demo

    The first run downloads the Python image and installs the libraries, which takes a few minutes. --rm removes the container when it finishes.

What success looks like: build lines scroll by, then the same three lines as in Step 5:

Answer: Order 9000001 has a delivery block (01) and a failed credit check (B).
Request took 590 ms.
Trace sent to http://phoenix:6006/v1/traces. Open http://localhost:6006 and choose project orchestrate-unit10.

Notice the address: http://phoenix:6006. Inside Compose, the container reached Phoenix by its service name. The OTEL_EXPORTER_OTLP_TRACES_ENDPOINT line in compose.yaml told the script where to send, without changing its code. Refresh Phoenix and you see one more trace.

Step 7: Confirm your BTP trial still works

The last topics of this unit deploy to SAP BTP. Check now, while there is time to fix it.

  1. Open your trial cockpit bookmark from the Unit 3 setup, or https://cockpit.hanatrial.ondemand.com/trial/. If it says the trial has expired or been deleted, create a new one as in Set up for Unit 3, Step 7. SAP's trial page says the trial lasts 90 days from registration and needs a login at least every 30 days.

  2. In your VS Code terminal, log in to Cloud Foundry exactly as in Set up for Unit 6, Step 5:

    cf login -a https://api.cf.YOUR-REGION.hana.ondemand.com

    Use --sso if your password is refused, as that step explains.

  3. Confirm where you are, and list your apps:

    cf target
    cf apps

What success looks like: cf target shows your trial org and space: dev. cf apps lists your apps, or says no apps were found; both are fine. If you deployed orchestrate-ai-api in Deploying AI apps on SAP BTP and it shows stopped, start it with cf start orchestrate-ai-api.

No SAP access right now? Skip this step. The check in Step 8 marks it LATER.

Step 8: Run the Unit 10 check

  1. In the course folder (not in unit10), create check_unit10.py, paste the code below and save. It uses only built-in Python, like the earlier checks.
"""Check that your computer is ready for Unit 10 (observability and containers).

Run it from your course folder:  python check_unit10.py
It uses built-in Python only. It looks for the Unit 10 libraries and files, checks that a
container engine is installed and running, checks whether Phoenix answers on this computer,
and asks the cf tool whether you are logged in to your BTP trial. It changes nothing.
"""
import importlib.metadata
import importlib.util
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.request

problems = 0


def report(ok: bool, label: str, fix: str = "", optional: bool = False) -> None:
    """Print one line: OK, MISSING (must fix) or LATER (optional for now)."""
    global problems
    if ok:
        print(f"  OK       {label}")
    elif optional:
        print(f"  LATER    {label}  ->  {fix}")
    else:
        problems += 1
        print(f"  MISSING  {label}  ->  {fix}")


def library(module: str, package: str) -> str:
    """Return the installed version of a library, or '' if it isn't installed."""
    try:
        if importlib.util.find_spec(module) is None:
            return ""
    except ModuleNotFoundError:
        return ""
    try:
        return importlib.metadata.version(package)
    except importlib.metadata.PackageNotFoundError:
        return "installed"


def run(command: str, *arguments: str) -> tuple:
    """Run a command; return (worked, first line of output). Never raises."""
    path = shutil.which(command)
    if not path:
        return False, ""
    try:
        done = subprocess.run([path, *arguments], capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.SubprocessError):
        return False, ""
    lines = (done.stdout + done.stderr).strip().splitlines()
    return done.returncode == 0, (lines[0] if lines else "")


def answers(url: str) -> bool:
    """True if a web server answers at this address."""
    try:
        urllib.request.urlopen(url, timeout=3)
        return True
    except urllib.error.HTTPError:
        return True
    except (urllib.error.URLError, OSError):
        return False


print("\n1. Python")
v = sys.version_info
report(v >= (3, 11), f"Python {v.major}.{v.minor}.{v.micro}",
       "the course needs Python 3.11 or newer (see Set up for Unit 2, Step 1)")
report(sys.prefix != sys.base_prefix, "virtual environment is active", "activate .venv (Step 1)")

print("\n2. Python libraries")
sdk = library("opentelemetry.sdk", "opentelemetry-sdk")
report(bool(sdk), f"opentelemetry-sdk {sdk}".strip(), "pip install -r requirements.txt (Step 2)")
otlp = library("opentelemetry.exporter.otlp.proto.http", "opentelemetry-exporter-otlp-proto-http")
report(bool(otlp), f"opentelemetry-exporter-otlp-proto-http {otlp}".strip(),
       "pip install -r requirements.txt (Step 2)")

print("\n3. Containers")
found, version = run("docker", "--version")
report(found, f"docker command ({version or 'not found'})",
       "install Docker Desktop or an alternative (Step 3), then open a new terminal")
running, _ = run("docker", "info") if found else (False, "")
report(running, "container engine is running",
       "start Docker Desktop (or your engine) and wait until it says it is running")
compose, compose_version = run("docker", "compose", "version") if found else (False, "")
report(compose, f"docker compose ({compose_version or 'not found'})",
       "comes with Docker Desktop; on Linux install the docker-compose-plugin package (Step 3)")

print("\n4. Trace viewer")
report(answers("http://localhost:6006"), "Phoenix answers on http://localhost:6006",
       "start it from the course folder: docker compose -f unit10/compose.yaml up -d (Step 4)",
       optional=True)

print("\n5. Course folder")
for name, step in [("trace_hello.py", "5"), ("Dockerfile", "6"), ("compose.yaml", "4")]:
    path = os.path.join("unit10", name)
    report(os.path.exists(path), path, f"create it (Step {step})")

print("\n6. SAP BTP trial (needed for Running AI on SAP BTP in production)")
cf_found, cf_version = run("cf", "version")
report(cf_found, f"cf command ({cf_version or 'not found'})",
       "installed in Set up for Unit 6, Step 4", optional=True)
if cf_found:
    logged_in, _ = run("cf", "target")
    report(logged_in, "logged in to Cloud Foundry (cf target)",
           "run cf login as in Step 7", optional=True)

print()
if problems:
    print(f"{problems} item(s) to fix. Fix them in order, then run this again.")
    sys.exit(1)
print("All set. Your computer is ready for Unit 10.")
  1. Run it:

    python check_unit10.py

What success looks like (from our test; your versions will differ, and our test machine had no cf tool):

1. Python
  OK       Python 3.13.16
  OK       virtual environment is active

2. Python libraries
  OK       opentelemetry-sdk 1.45.1
  OK       opentelemetry-exporter-otlp-proto-http 1.45.1

3. Containers
  OK       docker command (Docker version 29.8.2, build 7fc2dff)
  OK       container engine is running
  OK       docker compose (Docker Compose version v5.5.1)

4. Trace viewer
  OK       Phoenix answers on http://localhost:6006

5. Course folder
  OK       unit10/trace_hello.py
  OK       unit10/Dockerfile
  OK       unit10/compose.yaml

6. SAP BTP trial (needed for Running AI on SAP BTP in production)
  LATER    cf command (not found)  ->  installed in Set up for Unit 6, Step 4

All set. Your computer is ready for Unit 10.

LATER lines are fine for now. MISSING lines must be fixed before the next topic.

When you are done for the day, stop Phoenix. Your traces stay in its volume:

docker compose -f unit10/compose.yaml down

Step 9: Save your work in Git

  1. Check what Git sees:

    git status

    You should see requirements.txt, check_unit10.py and unit10/. You must not see .env.

  2. Save:

    git add requirements.txt check_unit10.py unit10
    git commit -m "Set up Unit 10: containers, Phoenix and a first trace"

How the code works

Part What it does
Resource.create({...}) Names the program that produced the spans (service.name) and the Phoenix project to file them under
TracerProvider + BatchSpanProcessor Collects finished spans and sends them in batches, so tracing doesn't slow the request
OTLPSpanExporter(endpoint=ENDPOINT) Sends spans over OTLP to Phoenix; ConsoleSpanExporter prints them instead with --console
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The standard setting for where traces go; Compose sets it so the same code works in a container
tracer.start_as_current_span(...) Starts a span; spans opened inside it become its children automatically
gen_ai.* attributes Standard names for the kind of step, model and token counts, which Phoenix turns into typed spans
app.* attributes Your own facts, such as the order number and lengths; never the prompt text or customer data
provider.shutdown() Sends the last batch before the program exits; without it, the trace can be lost
viewer_is_up() A friendly check, so a stopped Phoenix gives a clear message, not a stack trace
Dockerfile The recipe for the image: start from Python 3.13, install two libraries, copy the script, say what to run
compose.yaml Two services: Phoenix (always) and demo (only when asked for), plus a volume for traces

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found Python isn't installed, or the terminal can't find it Windows: repeat Unit 1, Step 1, then open a new terminal. macOS/Linux: use python3 until .venv is active
ModuleNotFoundError: No module named 'opentelemetry' The libraries aren't in the Python you are using Check for (.venv) in the prompt, then pip install -r requirements.txt
docker is not recognized, or command not found No engine installed, or the terminal opened before the install Finish Step 3, then open a new terminal
Cannot connect to the Docker daemon or failed to connect to the docker API The engine is installed but not running Start Docker Desktop (or Podman Desktop) and wait until it says it is running
Windows: Docker Desktop says WSL is missing or too old WSL isn't installed or is out of date In an administrator PowerShell, run wsl --update, then restart
Windows or Mac: Docker Desktop complains about virtualisation Hardware virtualisation is off Turn it on in the computer's BIOS/UEFI settings, or ask IT; company laptops may block it
Linux: permission denied on /var/run/docker.sock Your user isn't in the docker group yet Run the usermod command in Step 3, then log out and back in
pull access denied, Forbidden, or the download hangs Your network or proxy blocks Docker Hub Try another network, or ask IT to allow Docker Hub; meanwhile use --console in Step 5
Bind for 127.0.0.1:6006 failed: port is already allocated Something else uses port 6006, often an earlier Phoenix Run docker ps, stop the other container, or close the program using the port
No trace viewer answered at http://localhost:6006 Phoenix isn't running docker compose -f unit10/compose.yaml up -d, then wait a few seconds
Phoenix opens but shows no project The trace was sent to another address, or the page needs a refresh Refresh; check that OTEL_EXPORTER_OTLP_TRACES_ENDPOINT isn't set in your terminal to something else
cf is not recognized The Cloud Foundry CLI isn't installed Install it as in Set up for Unit 6, Step 4
cf target: Not logged in The cf session expired Run cf login again (Step 7)
BrokenPipeError when you pipe output You sent the script's output into head or similar Harmless; run the script without a pipe

Where this shows up in SAP

This section is short on purpose: the unit's BTP topic goes deeper.

  • SAP Cloud Logging. SAP's managed observability service on BTP. An August 2025 case study published by the OpenSearch project describes it as built on OpenSearch, collecting telemetry from Cloud Foundry and Kubernetes environments, with native OTLP ingestion. That is the protocol your script already speaks.
  • Kyma Telemetry module. Kyma's documentation describes three pipeline resources, LogPipeline, TracePipeline and MetricPipeline, that ship data over OTLP to an SAP Cloud Logging instance "with OpenTelemetry ingestion enabled". An app instrumented like trace_hello.py sends to the cluster's trace gateway instead of to Phoenix.
  • Containers on BTP. Kyma runs container images like the one you built in Step 6. Cloud Foundry, which Unit 6 used, builds the runnable image for you from your code with a buildpack, so you don't write a Dockerfile there.
  • Trial limits. We could not confirm, from SAP pages opened for this topic, whether SAP Cloud Logging can be added to a trial account. Treat it as a production service and plan your learning around Phoenix.
Need Use Why
Learn tracing today, free OpenTelemetry + Phoenix in a container No account; standard protocol
Production observability for apps on BTP SAP Cloud Logging Managed by SAP, accepts OTLP
Run container images on BTP Kyma runtime SAP's Kubernetes runtime on BTP
Deploy a Python app without writing a Dockerfile Cloud Foundry The buildpack builds the image

Production concerns

  • What goes in a span is a data decision. Prompts, answers and tool results can hold customer or employee data. Record facts (model, tokens, lengths, IDs your policy allows) by default. If you must record content, agree retention and access first.
  • Lock the viewer down. Phoenix ships with authentication off. Anything beyond your laptop needs PHOENIX_ENABLE_AUTH and a secret, which then require an API key for sending traces, as Phoenix documents.
  • Pin versions. latest is fine for learning. Phoenix's own documentation tells you to pin the image version for production; do the same for the Python base image and libraries.
  • Licences are part of the platform choice. Docker Desktop's terms depend on company size. Settle the engine standard before onboarding a team.
  • Don't trace in the hot path. BatchSpanProcessor sends in the background. Exporting synchronously on every span adds latency to the very requests you are measuring.
  • The conventions are still moving. OpenTelemetry's AI conventions are under active development. Keep your attribute names in one place in code, so a rename is one change.
  • Clean core still applies. Tracing an AI app that reads SAP doesn't need anything installed in S/4HANA. Keep instrumentation in your side-by-side app.

Pitfalls

  • Using localhost inside a container. Inside a container, localhost is the container itself. Use the Compose service name, as compose.yaml does.
  • Forgetting to flush. A short script that exits before the batch is sent loses its trace. Call provider.shutdown().
  • Opening the port to the network. 6006:6006 without 127.0.0.1: exposes an unauthenticated viewer to anyone on your network.
  • Inventing attribute names. model here and llm_model there makes traces unsearchable. Use the gen_ai.* names, and prefix your own with app..
  • Assuming the trial is still alive. A trial created in Unit 3 may have passed 90 days. Step 7 exists so you find out today, not on the day of a demo.

Exercise

Make a failed tool call visible in Phoenix. This is the pattern the observability topic builds on.

  1. In unit10/trace_hello.py, find the line parser.add_argument("--console", ... in main(). Add this line directly above it:

        parser.add_argument("--order", default="9000001", help="the sales order to ask about")
  2. Change the function's first line from def answer_question(tracer: trace.Tracer, question: str) -> str: to:

    def answer_question(tracer: trace.Tracer, question: str, question_order: str = "9000001") -> str:
  3. Inside the execute_tool get_sales_order block, directly above the line that starts with blocked = bool(, add these lines (same indentation as blocked):

                if question_order != ORDER["SalesOrder"]:
                    error = LookupError(f"sales order {question_order} not found")
                    span.record_exception(error)
                    span.set_status(trace.Status(trace.StatusCode.ERROR, str(error)))
                    return f"I could not find sales order {question_order}."
  4. In main(), find the line answer = answer_question(tracer, "Why is order 9000001 blocked?") and replace it with this one, which passes the order along:

        answer = answer_question(tracer, f"Why is order {args.order} blocked?", args.order)
  5. Run it with an order that doesn't exist, first on the console, then to Phoenix:

    python unit10/trace_hello.py --order 4711 --console
    python unit10/trace_hello.py --order 4711

    On the console you see three spans and no chat span: the assistant stopped before calling the model.

    Answer: I could not find sales order 4711.
  6. In Phoenix, open the newest trace. The execute_tool get_sales_order span has status ERROR with the message sales order 4711 not found.

  7. Run python check_unit10.py, then commit: git add unit10 && git commit -m "Unit 10: trace a failed tool call".

Done when Phoenix shows one trace with an execute_tool span in error status, the normal run still shows four spans with token counts, and check_unit10.py ends with All set.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1Your script runs inside the demo container. Why does it send traces to http://phoenix:6006, not http://localhost:6006?

    Answer: B. Each container has its own network view, so localhost points at the container itself. Compose lets containers reach each other by service name, which OTEL_EXPORTER_OTLP_TRACES_ENDPOINT sets for the demo.
  2. 2You remove provider.shutdown() from trace_hello.py. What is the most likely effect?

    Answer: D. BatchSpanProcessor holds finished spans and sends them in batches in the background. shutdown() flushes what is waiting; without it, a short script may end before anything is exported.
  3. 3Phoenix shows the chat span as kind LLM with token counts, though the script never mentions OpenInference. Why?

    Answer: A. Phoenix maps gen_ai.operation.name values such as chat, retrieval and execute_tool to its span kinds, and gen_ai.usage.* to token counts. Writing standard names is what makes that work.
  4. 4A colleague changes the port line in compose.yaml to "6006:6006" so a teammate can look at traces. What is the problem?

    Answer: B. Phoenix's documentation states authentication is disabled by default. Binding to 127.0.0.1 keeps it private; opening it to the network needs authentication switched on first.
  5. 5Which attributes does the script record on the root span, and why those?

    Answer: C. The script records facts about the request (question and answer lengths, the order number) and leaves text out. Prompts and answers can contain customer data, and OpenTelemetry's AI conventions don't capture content by default.
  6. 6The company has 4,000 staff and asks which engine to standardise on for the course. What do you say?

    Answer: B. Docker's licence makes Docker Desktop free only below 250 employees and $10 million revenue. Larger companies buy subscriptions or use Podman Desktop, or Docker Engine on Linux, which are open source.
  7. 7Your trace shows the chat span takes 7 of 8 seconds at month end. What would you look at first?

    Answer: C. The trace has already isolated the slow step. Token counts and model choice drive generation time, which the latency and model routing topics in this unit cover.
  8. 8Where would traces from an AI app running on SAP's Kyma runtime go in production?

    Answer: D. Kyma's Telemetry module provides a TracePipeline that sends OTLP data to an SAP Cloud Logging instance with OpenTelemetry ingestion enabled. Your instrumentation stays the same; only the destination changes.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in