← Back to blog
Guide 4 min read 炬鲸团队

Instrumenting Apps with the OpenTelemetry SDK: Auto vs. Manual Spans

Auto-instrumentation is easy but misses business context; manual spans are precise but costly. Java/Python startup commands, manual span patterns, attribute naming and how to verify your traces actually work end to end.

What auto-instrumentation does and does not cover

OpenTelemetry auto-instrumentation rewrites bytecode or hooks the interpreter at runtime via an injected agent, giving you HTTP requests, database calls and message-queue spans with zero code changes. It covers framework-level spans: inbound requests, outbound calls and SQL execution.

What it cannot cover is business context. Auto-instrumentation knows you called POST /orders, but not the order amount, the user ID, or which coupon was applied. Those are exactly the dimensions you miss when troubleshooting. It can tell you which call chain is slow, but not which kind of order is slow.

One thing auto-instrumentation gives you for free is context propagation: the trace and span IDs travel in headers (W3C traceparent), so a trace stitches together across every instrumented service. If a hop in the middle is not instrumented, the trace breaks there — the spans before and after look like two unrelated traces. When you see orphan spans in the console, look for the uninstrumented hop or a message queue that drops propagation headers.

Starting auto-instrumentation

For a Java service, one extra line in the launch command gets you a full trace:

java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://collector:4317 \
  -Dotel.traces.exporter=otlp \
  -jar order-service.jar

For Python, wrap the launch command with opentelemetry-instrument, and use opentelemetry-bootstrap to install the right instrumentor for each library:

opentelemetry-bootstrap -a install
opentelemetry-instrument \
  --traces_exporter otlp \
  --exporter_otlp_endpoint http://collector:4317 \
  python app.py

After it starts, send one request and check the console for a complete trace. If the trace is not there, fix that first — the problem is almost always on the collection or network side, not in your code.

When to write manual spans, and how

Do not wrap every bit of logic in a span. Reach for manual instrumentation in three places only:

  1. Business boundaries: wrap an order's journey from placement to callback in one span, linked by transaction_id.
  2. Key slow spots: third-party calls, large loops and batch jobs whose latency auto-instrumentation cannot see.
  3. Business attributes: add dimensions like user.id and order.amount to existing spans so you can filter by business conditions during an investigation.

Adding attributes to a span in Java:

Span span = tracer.spanBuilder("process-order").startSpan();
span.setAttribute("order.amount", 99.5);
span.setAttribute("user.id", uid);
try (Scope scope = span.makeCurrent()) {
    // business logic
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}

The same pattern is more concise in Python thanks to the context manager:

from opentelemetry import trace
tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span("process-order") as span:
    span.set_attribute("order.amount", 99.5)
    span.set_attribute("user.id", uid)
    try:
        process_order()
    except Exception as e:
        span.record_exception(e)
        span.set_status(trace.StatusCode.ERROR)
        raise

Do not skip recordException and setStatus(ERROR): alerting and sampling policies rely on span status codes. If you fail to mark errors, tail sampling may discard exactly the trace you need during an outage.

Naming attributes and verifying the pipeline

A common question is what belongs in a span attribute versus a log line or a span event. The rule of thumb: attributes are for dimensions you filter and aggregate on (order.amount, user.id, service.version); span events are for points in time within a span, such as a cache miss or a retry decision; and logs are for rich, unstructured text you need to read, not aggregate. Keep attributes small and typed, put transient moments in events, and leave prose to logs. Overloading spans with dozens of attributes makes traces hard to read and bloats storage without helping anyone find the problem faster.

Keep attribute names dotted and lowercase (order.amount, not orderAmount), and agree on a shared attribute list up front so every service uses the same names. After integration, verify in the console by trace id: is the span tree complete, are the business attributes present, and are error spans marked? Pick one failing request and confirm you can drill from the alert down to the exception stack. Only then is the integration truly done.

A useful smoke test: run the same failing request twice and confirm the two traces have identical structure. If an attribute value differs between runs where it should not, you have a bug in your instrumentation, not your service. And enable sampling early — even 100% for a single service in staging — so you learn how sampling interacts with your manual spans before production.