← Back to list

Understanding Opentelemetry Traces with Python

In my previous articles where we discussed on manual instrumentation of logs and metrics which are the first two pillars of Observability…

Devendra Kulkarni · 2026-06-08 10:16 · 1 claps · 6.7 min read
#traceability #opentelemetry #opentelemetry-traces #kubernetes #python
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Understanding Opentelemetry Traces with Python

In my previous articles where we discussed on manual instrumentation of logs and metrics which are the first two pillars of Observability. Today, lets understand the third pillar of Observability called “Traces”.

A trace, as the name suggests is a concept in Observability that traces/tracks entire operation/journey carried out by your application code. What I consider a best example to understand trace is an individuals daily routine:

Wake up -> Get Ready -> Breakfast-> Do some work -> Lunch -> Do some work -> High tea -> Do some work -> Dinner -> Sleep. And repeat!

Here, this complete cycle is a trace, while each job like getting ready, or having breakfast is a Span.

Technical definition for Trace is “The path of a request through your application”, where as Span is a unit of work or operation and these spans are the building blocks of Traces.

Let us try generating traces again using Python as our base language:

In order for our python code to emit traces, we need to ensure that the API and SDK packages are installed:

pip install opentelemetry-api
pip install opentelemetry-sdk

Wait a minute, why Does Tracing Require OpenTelemetry?

One thing I found interesting while learning traces was that, unlike logging and metrics, there is no widely used built-in tracing module in Python. Logging has had the logging module for decades, and Prometheus became the standard for metrics over the last decade. Tracing, however, went through years of competing tools like Jaeger, Zipkin, and SkyWalking, each with its own libraries and APIs. OpenTelemetry was created to bring everyone under a common standard, allowing developers to instrument applications once and send traces to any supported backend.

Moving back, lets use the exact example, given in the opentelemetry documentation for instrumenting traces in python and create our first python app that generates traces:

# Import needed tracing functions/modules
from opentelemetry import trace 
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
    BatchSpanProcessor,
    ConsoleSpanExporter,
)

provider = TracerProvider() # Initialize the tracerprovider which manages and creates traces.
processor = BatchSpanProcessor(ConsoleSpanExporter()) # Collect generated spans, batch them and print traces to console
provider.add_span_processor(processor) # Attach the processor to the tracerprovider.

# Sets the global default tracer provider
trace.set_tracer_provider(provider)

# Creates a tracer from the global tracer provider
tracer = trace.get_tracer("first-tracer")
print(tracer)

with tracer.start_as_current_span("first-span"):
   print("Hello, Welcome to tracing with OpenTelemetry!")

Output:

 ./venv/bin/python trace-basic.py

<opentelemetry.sdk.trace.Tracer object at 0x1085d3b60>

Hello, Welcome to tracing with OpenTelemetry!
{
    "name": "first-span",
    "context": {
        "trace_id": "0x675fe1bfae9c6d6ae96d5343fb6a8547",
        "span_id": "0x1f7ed779d5c1245f",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-06-07T14:55:45.043893Z",
    "end_time": "2026-06-07T14:55:45.043907Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {},
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.42.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}

Notice here that it generates a lot of information like span name, span id, trace id, parent id, start and end times, status code, attributes, events, resource.attributes, etc. To understand these concepts, I continued using the same simple Python application I had been using throughout my observability learning journey: a multiplication table generator and will tweak the information in the trace to understand them better.

Step 1 — Setting a meaningful name for span and getting attribute information

Often setting a vague name for span like we did earlier “first-span” is not a best practise, instead having a meaningful name helps in better debugging when a operation fails. On the other hand, attributes are a specific piece of metadata or a key-value pair that provides extra context of the trace. I wrote below code for our table generation to get our first trace with meaningful span and I will also get some attribute information out of it:

.
.

number: int = 5

with tracer.start_as_current_span("Generate Table") as span1: 
    print("Hello, This is a Table generation python app!")
    span1.set_attribute("table.number", number)
    for i in range(1, 11):
       print(f"{number} x {i} = {number * i}")

Output:

python span-attribute-table.py 
Hello, This is a Table generation python app!
5 x 1 = 5
5 x 2 = 10
5 x 3 = 15
5 x 4 = 20
5 x 5 = 25
5 x 6 = 30
5 x 7 = 35
5 x 8 = 40
5 x 9 = 45
5 x 10 = 50
{
    "name": "Generate Table", #<<< Span name set Correctly
    "context": {
        "trace_id": "0x2c90d1b81a4905134acf997914b55a9e",
        "span_id": "0xc044da6821050d63",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-06-08T04:35:15.684308Z",
    "end_time": "2026-06-08T04:35:15.684362Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "table.number": 5 # <<<<<<<<< Perfect, now you can see the attribute details
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.42.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}

Step 2: Introduce an error and observe the trace output

Now, lets go a step ahead and introduce an error, and for that, simply change the value of number 5 to “five” with a try/except block:

number = "five"

with tracer.start_as_current_span("Generate Table") as span1: 
    try:
        print("Hello, This is a Table generation python app!")
        span1.set_attribute("table.number", int(number))
        for i in range(1, 11):
            print(f"{number} x {i} = {number * i}")
    except ValueError as e:
        # print(e)
        # pass
        raise

Output:

python generate-tables-try-exception.py            
Hello, This is a Table generation python app!
Traceback (most recent call last):
  File "/Users/devendrakulkarni/github/python-dev/day4/generate-tables-try-exception.py", line 24, in <module>
    span1.set_attribute("table.number", int(number))
                                        ~~~^^^^^^^^
ValueError: invalid literal for int() with base 10: 'five'
{
    "name": "Generate Table",
    "context": {
        "trace_id": "0xd053d5ec4f7654a326d992c4814dd78a",
        "span_id": "0x7c0b4f6a5f340d25",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-06-08T09:17:05.096049Z",
    "end_time": "2026-06-08T09:17:05.097391Z",
    "status": {
        "status_code": "ERROR",
        "description": "ValueError: invalid literal for int() with base 10: 'five'"
    },
    "attributes": {},
    "events": [
        {
            "name": "exception",
            "timestamp": "2026-06-08T09:17:05.097381Z",
            "attributes": {
                "exception.type": "ValueError",
                "exception.message": "invalid literal for int() with base 10: 'five'",
                "exception.stacktrace": "Traceback (most recent call last):\n  File \"/Users/devendrakulkarni/github/python-dev/day4/venv/lib/python3.14/site-packages/opentelemetry/trace/__init__.py\", line 608, in use_span\n    yield span\n  File \"/Users/devendrakulkarni/github/python-dev/day4/venv/lib/python3.14/site-packages/opentelemetry/sdk/trace/__init__.py\", line 1177, in start_as_current_span\n    yield span\n  File \"/Users/devendrakulkarni/github/python-dev/day4/generate-tables-try-exception.py\", line 24, in <module>\n    span1.set_attribute(\"table.number\", int(number))\n                                        ~~~^^^^^^^^\nValueError: invalid literal for int() with base 10: 'five'\n",
                "exception.escaped": "False"
            }
        }
.
.

Notice how my span is showing the status_code as “ERROR”, with descripition as the ValueError and it also automatically created an event named “exception” with attributes that give additional context of the Error. That means, bydefault if the exception isnt handled span will automatically handle the status code and report failure. But on the other hand, as you can see in the code, I commented out #print and #pass statements, if you handle exception on your own, the exception, errors need to be explicitly set on the span, or else the span/trace will report that the code functioned well.

Step 3: Understanding Parent and Child spans

Till this point, we know what a trace is, how span functions, how it reports errors. But we earlier discussed that we will be checking each field in the trace output and noticed parent_id and for the above code, we still were getting only one span. Now, we will tweak our code to generate table for a list of numbers so it generates span for each number.

Generate Table [Parent span]
│
├── Generate Table for 2 [Child Span 1]
│     table.number=2
│
├── Generate Table for 5 [Child Span 2]
│     table.number=5
│
└── Generate Table for ten [Child span 3]
      table.number="ten"
      ERROR

The code will have changes to add child spans, but as they are part of same operation that is to generate tables, they would be a part of same Trace, so they will share same trace_id .

number = [ 2, 5 , "ten"]

with tracer.start_as_current_span("Generate Table") as parent_span: 
    try:
        print("Hello, This is a Table generation python app!")
        for num in number:
            with tracer.start_as_current_span(f"Generate Table for {num}") as child_span: 
                child_span.set_attribute("table.number",int(num))
                for i in range(1, 11):
                   print(f"{num} x {i} = {num * i}")
    except ValueError as e:
        raise

Ouptut:

{
    "name": "Generate Table for 2",
    "context": {
        "trace_id": "0xd78d7ce38ee78cd7804dcdd5cf37b1e2",
        "span_id": "0xb8139a6a9d820c57",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x5cc8e7ab1672a970",
    "start_time": "2026-06-08T09:40:35.532702Z",
    "end_time": "2026-06-08T09:40:35.532738Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "table.number": 2
    }
.
.

{
    "name": "Generate Table for 5",
    "context": {
        "trace_id": "0xd78d7ce38ee78cd7804dcdd5cf37b1e2",
        "span_id": "0x68678ad0ce79d7bd",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x5cc8e7ab1672a970",
    "start_time": "2026-06-08T09:40:35.532763Z",
    "end_time": "2026-06-08T09:40:35.532787Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "table.number": 5
    }
.
.
{
    "name": "Generate Table for ten",
    "context": {
        "trace_id": "0xd78d7ce38ee78cd7804dcdd5cf37b1e2",
        "span_id": "0xd48bdb7e24262809",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x5cc8e7ab1672a970",
    "start_time": "2026-06-08T09:40:35.532805Z",
    "end_time": "2026-06-08T09:40:35.534310Z",
    "status": {
        "status_code": "ERROR",
        "description": "ValueError: invalid literal for int() with base 10: 'ten'"
    },
    "attributes": {}
.
.
}

Parent span:
{
    "name": "Generate Table",
    "context": {
        "trace_id": "0xd78d7ce38ee78cd7804dcdd5cf37b1e2",
        "span_id": "0x5cc8e7ab1672a970",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-06-08T09:40:35.532634Z",
    "end_time": "2026-06-08T09:40:35.534503Z",
    "status": {
        "status_code": "ERROR",
        "description": "ValueError: invalid literal for int() with base 10: 'ten'"
    }

By creating a parent span (Generate Table) and child spans (Generate Table for 2, 5, and ten), I observed how OpenTelemetry builds a trace hierarchy. All spans shared the same trace_id, indicating they belonged to the same execution flow, while each span had a unique span_id. The child spans used the parent's span_id as their parent_id, allowing OpenTelemetry to reconstruct the relationship between operations and pinpoint that the failure occurred specifically in the Generate Table for ten span.

Key Takeaways

After experimenting with traces using a simple multiplication table application, these concepts became clear:

  • A trace represents an entire workflow.
  • A span represents a single operation within that workflow.
  • Attributes provide additional context about an operation.
  • Trace IDs connect related spans together.
  • Span IDs uniquely identify individual operations.
  • Parent IDs establish relationships between spans.
  • Exceptions can automatically be captured and associated with spans.
  • Traces help answer not only what failed, but also where and why it failed.

What’s Next?

Now that I understand logs, metrics, and traces individually, the next step is combining all three signals in a single application.

The real power of observability emerges when metrics indicate a problem, traces identify where it occurred, and logs explain why it happened.

That is the journey I plan to explore next.

Wake up -> Get Ready -> Breakfast-> Do some work -> Lunch -> Do some work -> High tea -> Do some work -> Dinner -> Sleep. And repeat!


메타데이터
post_id
8efa85b14ec4
slug
understanding-opentelemetry-traces-with-python-8efa85b14ec4
url
https://medium.com/@devendrakulkarni138057/understanding-opentelemetry-traces-with-python-8efa85b14ec4
canonical_url
https://medium.com/@devendrakulkarni138057/understanding-opentelemetry-traces-with-python-8efa85b14ec4
author_url
https://medium.com/@devendrakulkarni138057
status
ok
fetched_at
2026-06-09 15:37:30