Module 8: Manual Traces
Auto-instrumentation gave you spans for HTTP, your web framework and Postgres. Those describe your infrastructure. This module is about spans that describe your domain β and about the moment a trace crosses a service boundary and still holds together. π§΅
Learning Objectives
By the end of this module, you will:
- Create custom spans around business operations, with useful attributes and events
- Record errors on spans so that they’re visible as errors, not just as text
- Name spans in a way that doesn’t explode your backend
- Use semantic conventions, and know what they buy you
- Propagate context across services manually, having first watched it break
8.1 β Concepts
A span is one operation with a start, an end, and a place in a tree:
| Part | Notes |
|---|---|
| name | Low cardinality. GET /todos/:id, never GET /todos/8f3a⦠|
| kind | SERVER, CLIENT, INTERNAL, PRODUCER, CONSUMER |
| attributes | High cardinality is fine and encouraged here |
| events | Timestamped points inside the span |
| status | UNSET, OK, or ERROR |
| parent | What makes it a tree |
β The naming rule: low cardinality in the name, high cardinality in the attributes. This is the exact inverse of the metrics rule from Module 6.2, and for the same underlying reason β the name is an index key, the attributes are payload.
Context propagation is how the span tree survives a network hop. The trace ID and the active span’s ID travel in the W3C traceparent header; the receiving service reads it and makes its own span a child. When someone says “the trace broke at this boundary”, the header didn’t make it. That’s nearly always the whole story.
Exercise 8.2 β Custom spans around business operations
You should be able to answer this by the end: could a colleague debug a failed create at 3am from this trace alone, without opening the source?
Do
- Wrap
enrichin a span. It’s the ideal first candidate: real business logic, no library instrumentation of its own, currently a complete blind spot.
Node.js / Express
In src/routes/todos.js, where enrich is already imported:
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('todo-api');
async function enrichTraced(todo) {
return tracer.startActiveSpan('todo.enrich', async (span) => {
try {
span.setAttribute('todo.list', todo.list);
span.setAttribute('todo.tag_count', todo.tags.length);
span.setAttribute('todo.priority', todo.priority);
const result = await enrich(todo);
span.addEvent('enrichment.complete', { source: result.source });
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}
});
}
β οΈ
span.end()in afinallyblock, every time. A span that is never ended is never exported, and the trace silently loses a subtree.
Then make the POST /todos handler call it instead of enrich:
const enriched = await enrichTraced(todo);
Python / Flask
In todo_api/routes.py, where enrich is already imported:
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("todo-api")
def enrich_traced(todo):
with tracer.start_as_current_span(
"todo.enrich", record_exception=False, set_status_on_exception=False,
) as span:
try:
span.set_attribute("todo.list", todo.list)
span.set_attribute("todo.tag_count", len(todo.tags))
span.set_attribute("todo.priority", todo.priority)
result = enrich(todo)
span.add_event("enrichment.complete", {"source": result["source"]})
span.set_status(Status(StatusCode.OK))
return result
except Exception as err:
span.record_exception(err)
span.set_status(Status(StatusCode.ERROR, str(err)))
raise
βΉοΈ The context manager ends the span for you, including on exception. By default it also records the exception and marks the span as an error; those two are switched off here so that the code does it explicitly, as on the other tracks, instead of twice.
Then make the POST /todos handler call it instead of enrich:
enriched = enrich_traced(todo)
Go / net/http
Create a new file, tracing.go, next to routes.go β it’s the same package, so
enrich is already in reach:
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("todo-api")
func enrichTraced(ctx context.Context, todo Todo) (Enriched, error) {
_, span := tracer.Start(ctx, "todo.enrich")
defer span.End()
span.SetAttributes(
attribute.String("todo.list", todo.List),
attribute.Int("todo.tag_count", len(todo.Tags)),
attribute.Int("todo.priority", todo.Priority),
)
result, err := enrich(todo)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return result, err
}
span.AddEvent("enrichment.complete", trace.WithAttributes(attribute.String("source", result.Source)))
span.SetStatus(codes.Ok, "")
return result, nil
}
β οΈ
defer span.End()right aftertracer.Start, every time. A span that is never ended is never exported, and the trace silently loses a subtree.
Then make postTodo call it instead of enrich, passing the request’s context:
enriched, err := enrichTraced(ctx, todo)
-
Do the same for two more operations. Suggested, in order of payoff:
notifyUrgent/notify_urgentβ the outbound call, so you can see it separately from the raw HTTP spanreadSummary/read_summaryβ the cache read path, with an attribute for whether it hit
For the cache one, wrap the
readSummary/read_summarycall in theGET /todoshandler and record the outcome. It already returns whether it hit and the key it looked up:
Node.js / Express
const cached = tracer.startActiveSpan('cache.read', (span) => {
const result = readSummary(list);
span.setAttribute('cache.hit', result.hit);
span.setAttribute('cache.key', result.key);
span.end();
return result;
});
Python / Flask
with tracer.start_as_current_span("cache.read") as span:
hit, key, value = read_summary(list_name)
span.set_attribute("cache.hit", hit)
span.set_attribute("cache.key", key)
Go / net/http
_, span := tracer.Start(r.Context(), "cache.read")
hit, key, value := readSummary(list)
span.SetAttributes(attribute.Bool("cache.hit", hit), attribute.String("cache.key", key))
span.End()
- Save the file, generate load, and then do the 3am test: pull up a trace for
POST /todosand ask whether you could debug it without the source. Iterate on the attributes until the answer is yes. This iteration is the exercise β the first set of attributes you pick is never the right one.
Verify in the Victoria UI
VictoriaTraces vmui (http://localhost:10428/select/vmui):
_time:15m "resource_attr:service.name":=todo-api name:="todo.enrich"
You should see: your span, with todo.list, todo.tag_count and todo.priority on it, nested under the POST /todos server span.
Find a failure:
_time:15m "resource_attr:service.name":=todo-api status_code:=2
status_code is 0 for UNSET, 1 for OK and 2 for ERROR. If there are no failures yet, ./loadgen.sh 1m --errors adds some.
You should see: spans marked as errors with an exception recorded β not just a log line elsewhere that happens to mention a failure.
Verify in Grafana
Open Grafana at http://localhost:3000.
Explore β VictoriaTraces (Jaeger) β search service todo-api, operation POST /todos. In the waterfall, your todo.enrich span should sit after the INSERTs and before the UPDATE that stores the slug.
Exercise 8.3 β Semantic conventions
You should be able to answer this by the end: what do you get for using a standard attribute name instead of your own?
Do
-
Open the semantic conventions registry and look at the HTTP, database and error sections.
-
Look at the attributes you added in 8.2:
todo.list,todo.tag_count,todo.priority,cache.hit,cache.key. They describe your domain, and no standard covers them, so a namespace of your own βtodo.β is the right call. But one thing yourtodo.enrichspan records does have a standard name: what kind of error happened. The standard iserror.type. -
Add it where
todo.enrichrecords the failure:
Node.js / Express
} catch (err) {
span.recordException(err);
span.setAttribute('error.type', err.name); // e.g. TypeError
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
}
Python / Flask
except Exception as err:
span.record_exception(err)
span.set_attribute("error.type", type(err).__name__) # e.g. ValueError
span.set_status(Status(StatusCode.ERROR, str(err)))
raise
Go / net/http
// add "fmt" to the imports of tracing.go
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String("error.type", fmt.Sprintf("%T", err))) // e.g. *errors.errorString
span.SetStatus(codes.Error, err.Error())
return result, err
}
Save the file and generate a few failures: ./loadgen.sh 1m --errors.
-
Now look at what the auto-instrumentation put on the
POST /todosserver span:_time:15m "resource_attr:service.name":=todo-api name:="POST /todos"
Node.js / Express
You will find span_attr:http.request.method, span_attr:http.response.status_code and
span_attr:url.path β the names the registry you just read uses. The JavaScript
instrumentation libraries have already moved to them.
Python / Flask
You will find span_attr:http.method, span_attr:http.status_code and
span_attr:http.target β the old names. The registry you just read says
http.request.method, http.response.status_code and url.path. The convention moved;
the Python instrumentation libraries have not caught up yet.
Go / net/http
You will find span_attr:http.request.method, span_attr:http.response.status_code and
span_attr:url.path β the names the registry you just read uses. The instrumentation
otelc compiles in already uses them. It even sets error.type itself on a failed
request: to the status code, 500.
The same request gets different attribute names depending on which SDK, and which version of it, produced it. That is not a footnote: every dashboard, alert and saved query written against the old names breaks on the day the libraries catch up. That’s why, in production, you pin your instrumentation versions and upgrade them on purpose.
Verify in the Victoria UI
Open VictoriaTraces at http://localhost:10428/select/vmui.
_time:15m "resource_attr:service.name":=todo-api name:="todo.enrich" "span_attr:error.type":*
You should see: the failed todo.enrich spans, each with its error.type.
_time:15m "span_attr:http.route":="/todos" status_code:=2
You should see: the failed requests on that route, selected by http.route β a standard
name all three tracks agree on. The same query works against any OTel-instrumented service.
Verify in Grafana
Open Grafana at http://localhost:3000.
Copy the trace_id of one of the failed todo.enrich spans. In Explore β VictoriaTraces (Jaeger), paste it into the trace ID search and expand todo.enrich: error.type sits next to the recorded exception.
Exercise 8.4 β Cross-service propagation
You should be able to answer this by the end: what exactly travels between two services to make one trace out of two?
Do
Step 1 β Confirm it already works.
Create an urgent todo:
Node.js / Express
curl -s -X POST localhost:8081/todos -H 'content-type: application/json' -d '{"title":"Call the bank","list":"work","tags":["urgent"]}'
Python / Flask
curl -s -X POST localhost:8082/todos -H 'content-type: application/json' -d '{"title":"Call the bank","list":"work","tags":["urgent"]}'
Go / net/http
curl -s -X POST localhost:8085/todos -H 'content-type: application/json' -d '{"title":"Call the bank","list":"work","tags":["urgent"]}'
Find notify-service’s side of it in VictoriaTraces:
_time:5m "resource_attr:service.name":=notify-service name:="POST /notify"
Copy its trace_id and pull the whole trace:
trace_id:=<paste it here>
You should see spans from both services in one trace. Nobody wrote any propagation
code β the HTTP client instrumentation injected traceparent on the way out, and the HTTP
server instrumentation in notify-service extracted it on the way in.
Step 2 β Break it, deliberately.
Node.js / Express
Imagine the HTTP client library your API uses had no instrumentation β common enough for
less popular libraries. Switch its instrumentation off: add one line to your API’s .env
and recreate the container.
notify.js calls notify-service with fetch, which the undici instrumentation covers:
# track-js/todo-api/.env
OTEL_NODE_DISABLED_INSTRUMENTATIONS=undici
docker compose up -d --force-recreate todo-api-js
Python / Flask
Imagine the HTTP client library your API uses had no instrumentation β common enough for
less popular libraries. Switch its instrumentation off: add one line to your API’s .env
and recreate the container.
notify.py calls notify-service with requests, which runs on top of urllib3; both are
instrumented:
# track-py/todo-api/.env
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=requests,urllib3
docker compose up -d --force-recreate todo-api-py
Go / net/http
In Go, the trace travels inside the context.Context you pass from function to function,
and the HTTP client instrumentation reads it from the request’s context to inject
traceparent. Lose the context and you lose the trace β the most common way it happens in
Go. In notify.go, make post build its request with context.Background() instead of
the ctx it was given:
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, notifyURL+path, bytes.NewReader(payload))
Save the file.
Create another urgent todo and repeat step 1. You now have two orphan traces instead
of one: your service’s, which ends at the database work, and one that starts without it
and contains notify-service’s span.
π§ Sit with this for a moment. This “before” state is what most real distributed systems look like β every service instrumented, no trace that crosses more than one of them. It looks like it’s working right up until you need it.
Step 3 β Fix it by hand.
Node.js / Express
Inject the context into the request’s headers yourself, with the propagation API.
In src/notify.js:
const { context, propagation } = require('@opentelemetry/api');
async function post(path, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const headers = { 'content-type': 'application/json' };
propagation.inject(context.active(), headers); // adds traceparent
const res = await fetch(`${NOTIFY_URL}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
return await res.json();
} finally {
clearTimeout(timer);
}
}
Save the file, create an urgent todo and confirm the trace is whole again. One thing is
different: there is no client POST span between your API’s spans and notify-service’s,
because nothing instruments the client any more. The traceparent header alone is enough
to join the two services.
Python / Flask
Inject the context into the request’s headers yourself, with the propagation API.
In todo_api/notify.py:
from opentelemetry.propagate import inject
def _post(path: str, body: dict) -> dict:
headers = {"content-type": "application/json"}
inject(headers) # adds traceparent
response = requests.post(
f"{NOTIFY_URL}{path}",
json=body,
headers=headers,
timeout=TIMEOUT_S,
)
return response.json()
Save the file, create an urgent todo and confirm the trace is whole again. One thing is
different: there is no client POST span between your API’s spans and notify-service’s,
because nothing instruments the client any more. The traceparent header alone is enough
to join the two services.
Go / net/http
Give the request its context back β ctx instead of context.Background():
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL+path, bytes.NewReader(payload))
Save the file, create an urgent todo and confirm the trace is whole again. You didn’t
inject anything yourself: the instrumentation does that, as long as the context reaches
it. In Go, propagating the trace between services starts with passing ctx inside
yours.
Step 4 β Put it back.
Node.js / Express
Remove the line you added to .env, recreate the container, and take the inject change
back out of the notify client. With the instrumentation on again, injecting by hand as well
sends the header twice β and on the JS track that breaks the trace all over again.
Python / Flask
Remove the line you added to .env, recreate the container, and take the inject change
back out of the notify client. With the instrumentation on again, injecting by hand as well
sends the header twice, which you don’t want.
Go / net/http
There’s nothing to put back: step 3 left notify.go the way it was.
Verify in the Victoria UI
Open VictoriaTraces at http://localhost:10428/select/vmui.
Take the trace of the most recent call to notify-service:
_time:5m "resource_attr:service.name":=notify-service name:="POST /notify" | fields _time, trace_id
trace_id:=<paste it here>
You should see: while broken, no POST /todos in that trace β it starts without your
request. After the fix, the same two queries show POST /todos and everything under it,
notify-service’s span included, in one trace.
Verify in Grafana
Open Grafana at http://localhost:3000.
Explore β VictoriaTraces (Jaeger), search service todo-api, operation POST /todos. Before the fix, the waterfall of an urgent create ends at your service. After it, the waterfall continues into notify-service, and you can see exactly how much of the request it accounts for.