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.
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.
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.
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.
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.
"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.
Pick one answer for each question. The explanation appears after you choose.
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.
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.
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.
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.
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.
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
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.
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.
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.
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:
A tracer provider, which owns the configuration.
A span processor. BatchSpanProcessor collects finished spans and sends them in batches, off the request's path.
An exporter, which says where spans go. OTLPSpanExporter sends over OTLP, OpenTelemetry's protocol; ConsoleSpanExporter prints them.
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.
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
Open VS Code, choose File > Open Folder, and open orchestrate-course.
Open a terminal: Terminal > New Terminal.
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.
Double-click Docker Desktop Installer.exe. Choose the per-user install if offered (it needs no administrator rights).
On the Configuration page, keep Use WSL 2 instead of Hyper-V ticked. Finish the wizard and click Close.
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.
Wait until Docker Desktop says the engine is running.
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.
Open the downloaded file and follow Docker's page to move Docker into Applications.
Start Docker from Applications and accept the Docker Subscription Service Agreement when it is shown.
Wait until Docker Desktop says it is running.
Option C: Docker Engine on Linux (Ubuntu shown)
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.
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.
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:
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.
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.
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.
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.
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()
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.
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.
In the browser, refresh http://localhost:6006. Click the project orchestrate-unit10.
On the Spans tab you see one row of kind agent, named invoke_agent blocked…. Click its name.
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.
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.
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.
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"]
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.
The last topics of this unit deploy to SAP BTP. Check now, while there is time to fix it.
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.
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.
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.
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.")
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:
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.
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.
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}."
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)
Run it with an order that doesn't exist, first on the console, then to Phoenix:
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.
In Phoenix, open the newest trace. The execute_tool get_sales_order span has status ERROR with the message sales order 4711 not found.
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.
Pick one answer for each question. The explanation appears after you choose.
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.
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.
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.
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.
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.
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.
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.
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Sources
Docker Desktop license agreement (Docker documentation)— free for small businesses (fewer than 250 employees and less than $10 million annual revenue), personal use, education and non-commercial open source; paid Pro, Team or Business subscription otherwise
Install Docker Desktop on Windows (Docker documentation)— Windows 10 22H2 or Windows 11 23H2 and later; WSL 2.1.5 or later; 8 GB RAM; virtualization enabled; per-user or all-users install; Use WSL 2 instead of Hyper-V; accept the Subscription Service Agreement on first start; docker-users group is equivalent to administrator rights
Phoenix: Docker deployment and licence (Arize documentation)— arizephoenix/phoenix image; port 6006 serves the UI and OTLP over HTTP, 4317 OTLP gRPC; PHOENIX_WORKING_DIR with a volume for persistence; pin versions for production. Licence page says self-hosting is free under Elastic License 2.0
SAP BTP trial (sap.com)— free trial for 90 days from registration, with at least one login within 30 days; the free tier is a commercial model under BTPEA or Pay-As-You-Go